feat(preferences): reset actions, account-scope copy, and frontend tests

Wire the per-domain reset actions into the settings surface: the
appearance section footer and navigation section each offer a
complete-domain reset, and the recovery section's interface reset
runs both domains through the shared handler with a reload only after
both tombstones settle successfully.

- Settings registry: appearance scope moves from browser to account,
  with copy that states where preferences now live
- ThemeQuickSwitch: the save-location line reflects account syncing
- Tests: dirty-field merge against a late hydration GET, identity
  guards (cache ownership, queue invalidation on account switch,
  loading-state no-op), corrupt-row repair through the hook path,
  remote-reset precedence, reset settle/failure handling, registry
  scope assertions, and the eligibility readiness bridge
This commit is contained in:
Anso
2026-09-07 13:53:51 -04:00
parent edf584af3a
commit 4133854e08
15 changed files with 1243 additions and 49 deletions
@@ -0,0 +1,136 @@
/**
* Readiness bridge at the producer level: useViewNavigationState publishes
* settled quick-link eligibility into the module store with ownership captured
* from its authorization snapshot. Covers: publish on settle, nothing before
* settle, a superseded producer's publication masked after an identity bump,
* and teardown scoped to ownership (a superseded producer's unmount cannot
* erase a newer account's publication).
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import * as AuthContext from '@/context/AuthContext';
import * as LicenseContext from '@/context/LicenseContext';
import * as NodeContext from '@/context/NodeContext';
import { useViewNavigationState } from '../hooks/useViewNavigationState';
import {
bumpGeneration,
clearEligibility,
currentGeneration,
getSettledEligibility,
} from '@/lib/preferences/preferenceEvents';
import { setCurrentSyncUser } from '@/lib/preferences/syncBus';
vi.mock('@/context/AuthContext');
vi.mock('@/context/LicenseContext');
vi.mock('@/context/NodeContext');
const useExperimentalMock = vi.fn(() => ({ experimental: true, experimentalReady: true }));
vi.mock('@/hooks/useExperimental', () => ({
useExperimental: () => useExperimentalMock(),
}));
interface AuthMock {
userId: number;
isAdmin: boolean;
can: (p: string) => boolean;
permissionsStatus: 'ready' | 'loading' | 'error';
}
function mockAuth(m: AuthMock) {
vi.mocked(AuthContext.useAuth).mockReturnValue({
isAdmin: m.isAdmin,
can: m.can,
permissionsStatus: m.permissionsStatus,
user: { userId: m.userId },
} as unknown as ReturnType<typeof AuthContext.useAuth>);
}
function mockActiveNode(type: 'local' | 'remote' | null) {
vi.mocked(NodeContext.useNodes).mockReturnValue({
activeNode: type === null ? null : { type, id: 1, name: 'n' },
} as unknown as ReturnType<typeof NodeContext.useNodes>);
}
function mockLicense(isPaid: boolean, licenseStatus: 'ready' | 'loading' | 'error' = 'ready') {
vi.mocked(LicenseContext.useLicense).mockReturnValue({
isPaid,
licenseStatus,
} as unknown as ReturnType<typeof LicenseContext.useLicense>);
}
const ADMIN_CAN = (p: string) =>
p === 'system:audit' || p === 'system:console' || p === 'node:read' || p === 'stack:deploy' || p === 'node:manage';
const VIEWER_CAN = (p: string) => p === 'node:read';
const ADMIN_ELIGIBILITY = ['dashboard', 'fleet', 'resources', 'security', 'auto-updates', 'scheduled-ops'];
const VIEWER_ELIGIBILITY = ['dashboard', 'fleet', 'resources', 'security'];
describe('useViewNavigationState eligibility publication (readiness bridge)', () => {
beforeEach(() => {
mockActiveNode('local');
useExperimentalMock.mockReturnValue({ experimental: true, experimentalReady: true });
});
afterEach(() => {
const pub = getSettledEligibility();
if (pub) clearEligibility(pub.ownership);
});
it('publishes settled eligibility with ownership captured from the producing snapshot', () => {
const generation = currentGeneration();
mockAuth({ userId: 5, isAdmin: true, can: ADMIN_CAN, permissionsStatus: 'ready' });
mockLicense(true);
setCurrentSyncUser(5);
const { result } = renderHook(() => useViewNavigationState());
expect(result.current.defaultQuickLinkEligibility).toEqual(ADMIN_ELIGIBILITY);
const pub = getSettledEligibility();
expect(pub?.eligibleIds).toEqual(ADMIN_ELIGIBILITY);
// Ownership binds the publication to the account and generation whose
// authorization snapshot produced it, not to whoever reads it later.
expect(pub?.ownership).toEqual({ userId: 5, generation });
});
it('publishes nothing while permissions are still loading', () => {
mockAuth({ userId: 5, isAdmin: true, can: ADMIN_CAN, permissionsStatus: 'loading' });
mockLicense(true);
setCurrentSyncUser(5);
renderHook(() => useViewNavigationState());
expect(getSettledEligibility()).toBeNull();
});
it('a superseded producer is masked after an identity bump and its teardown cannot erase the new publication', () => {
// Producer A (account 5) settles first.
mockAuth({ userId: 5, isAdmin: true, can: ADMIN_CAN, permissionsStatus: 'ready' });
mockLicense(true);
setCurrentSyncUser(5);
const producerA = renderHook(() => useViewNavigationState());
expect(getSettledEligibility()?.ownership.userId).toBe(5);
// Account switch: identity generation bumps. A's stored publication reads
// null immediately, before the new account's producer has published.
act(() => {
bumpGeneration();
});
expect(getSettledEligibility()).toBeNull();
// Producer B mounts for the new account (viewer): it captures the new
// identity and its publication becomes the visible one. A is still
// mounted, exactly like a stale tab's producer that has not noticed.
mockAuth({ userId: 6, isAdmin: false, can: VIEWER_CAN, permissionsStatus: 'ready' });
mockLicense(false);
setCurrentSyncUser(6);
const producerB = renderHook(() => useViewNavigationState());
expect(getSettledEligibility()?.ownership.userId).toBe(6);
expect(getSettledEligibility()?.eligibleIds).toEqual(VIEWER_ELIGIBILITY);
// A unmounts late: its teardown carries A's ownership and must not erase
// B's publication.
producerA.unmount();
expect(getSettledEligibility()?.ownership.userId).toBe(6);
// B's own teardown clears B's publication.
producerB.unmount();
expect(getSettledEligibility()).toBeNull();
});
});
@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { useNodes } from '@/context/NodeContext';
@@ -21,6 +21,12 @@ import { useExperimental } from '@/hooks/useExperimental';
import { canScheduleAny } from '@/lib/scheduledActions';
import { buildNavigationModel } from '@/lib/navigation/buildNavigationModel';
import { recommendedQuickLinkIds, type NavDestination } from '@/lib/navigation/appNavRegistry';
import {
setEligibilitySettled,
clearEligibility,
currentGeneration,
type EligibilityOwnership,
} from '@/lib/preferences/preferenceEvents';
export type { ActiveView };
export { HUB_ONLY_VIEWS };
@@ -36,7 +42,7 @@ interface UseViewNavigationStateOptions {
export function useViewNavigationState(options?: UseViewNavigationStateOptions) {
const { onNavigateToDashboard, hasFleetCapability = false, containerLabelsEnabled = false } = options ?? {};
const { isAdmin, can, permissionsStatus, permissions } = useAuth();
const { isAdmin, can, permissionsStatus, permissions, user } = useAuth();
const { isPaid, licenseStatus } = useLicense();
const { activeNode } = useNodes();
const isRemote = activeNode?.type === 'remote';
@@ -143,6 +149,40 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
return recommendedQuickLinkIds.filter((id) => !isViewHidden(id, roleCtx));
}, [reachCtx]);
// Readiness bridge (per-user preference sync): publish settled eligibility to
// the module-level store the always-mounted sync owner consumes. Ownership is
// captured from THIS authorization snapshot (the account id and identity
// generation that produced this eligibility value), so a publication from a
// superseded producer (account switched, logout) is rejected by the store and
// can never appear current for the next account. Teardown clears only its own
// ownership, so an obsolete cleanup cannot erase a newer account's publication.
const eligibilityOwnershipRef = useRef<EligibilityOwnership | null>(null);
const publishedEligibilityRef = useRef<readonly ActiveView[] | null | undefined>(undefined);
useEffect(() => {
if (!authzReady(reachCtx)) return;
// Capture ownership from the snapshot this eligibility value came from.
eligibilityOwnershipRef.current = { userId: user?.userId ?? null, generation: currentGeneration() };
}, [reachCtx, user, permissionsStatus]);
useEffect(() => {
if (!authzReady(reachCtx)) return;
const ownership = eligibilityOwnershipRef.current;
if (!ownership) return;
if (publishedEligibilityRef.current === defaultQuickLinkEligibility) return;
publishedEligibilityRef.current = defaultQuickLinkEligibility;
setEligibilitySettled(ownership, defaultQuickLinkEligibility === null ? null : [...defaultQuickLinkEligibility]);
}, [reachCtx, defaultQuickLinkEligibility]);
useEffect(() => {
// Teardown is scoped to the ownership captured above: only a publication
// still owned by THIS producer is cleared.
return () => {
const ownership = eligibilityOwnershipRef.current;
if (ownership) clearEligibility(ownership);
};
}, []);
useEffect(() => {
if (!authzReady(reachCtx)) return;
const normalized = normalizeHiddenView(activeView, reachCtx);
@@ -1,4 +1,4 @@
import { Check, Info } from 'lucide-react';
import { Check, Info, RotateCcw } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Combobox } from '@/components/ui/combobox';
import { Slider } from '@/components/ui/slider';
@@ -147,9 +147,13 @@ function VisualCard({
export function AppearanceSection({
quickLinkCandidates = [],
defaultQuickLinkEligibility,
onResetAppearance,
onResetNavigation,
}: {
quickLinkCandidates?: NavDestination[];
defaultQuickLinkEligibility?: ActiveView[] | null;
onResetAppearance: () => void;
onResetNavigation: () => void;
}) {
const [density, setDensity] = useDensity();
const [chipColorMode, setChipColorMode] = useLogChipColorMode();
@@ -233,7 +237,7 @@ export function AppearanceSection({
</SettingsField>
</SettingsSection>
<SettingsSection title="Readability" kicker="this browser">
<SettingsSection title="Readability" kicker="your account">
<SettingsField
label="Readability mode"
helper="One switch: upright headings, muted flat charts, reduced effects, and a contrast lift."
@@ -275,7 +279,7 @@ export function AppearanceSection({
</SettingsField>
</SettingsSection>
<SettingsSection title="Motion & effects" kicker="this browser">
<SettingsSection title="Motion & effects" kicker="your account">
{!reducedMotion ? (
<SettingsCallout
tone="warn"
@@ -337,7 +341,7 @@ export function AppearanceSection({
</SettingsActions>
</SettingsSection>
<SettingsSection title="Theme" kicker="this browser">
<SettingsSection title="Theme" kicker="your account">
<div className="pt-3">
<ThemePreview />
</div>
@@ -395,7 +399,7 @@ export function AppearanceSection({
</SettingsActions>
</SettingsSection>
<SettingsSection title="Typography" kicker="this browser">
<SettingsSection title="Typography" kicker="your account">
<SettingsField
label="Interface font"
helper="The sans face for body, labels, navigation, and buttons. Heading style follows your Visual style choice."
@@ -432,7 +436,7 @@ export function AppearanceSection({
</SettingsField>
</SettingsSection>
<SettingsSection title="Display" kicker="this browser">
<SettingsSection title="Display" kicker="your account">
<SettingsField
label="Density"
helper={DENSITY_DESCRIPTIONS[density]}
@@ -460,7 +464,7 @@ export function AppearanceSection({
</SettingsField>
</SettingsSection>
<SettingsSection title="Navigation" kicker="this browser">
<SettingsSection title="Navigation" kicker="your account">
<SettingsField
label="Navigation style"
helper="Compact launcher is the recommended default: destinations live in a menu with optional quick links. Smart bar keeps primary destinations visible with the rest under More."
@@ -473,6 +477,17 @@ export function AppearanceSection({
/>
</SettingsField>
<SettingsField
label="Reset all navigation preferences"
helper="Restore the navigation style, quick links, and labels to their defaults, for your account on every device."
align="start"
>
<SettingsSecondaryButton type="button" onClick={onResetNavigation} aria-label="Reset all navigation preferences">
<RotateCcw className="h-4 w-4" />
Reset
</SettingsSecondaryButton>
</SettingsField>
{topNavMode === 'smart' && (
<SettingsField
label="Top navigation labels"
@@ -557,8 +572,19 @@ export function AppearanceSection({
</SettingsSection>
<p className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle/70">
saved to this browser only · every device remembers its own choice
saved to your account · every device picks it up on sign-in
</p>
<SettingsField
label="Reset all appearance preferences"
helper="Restore every visual, display, and navigation preference in this section to its default, for your account on every device."
align="start"
>
<SettingsSecondaryButton type="button" onClick={onResetAppearance} aria-label="Reset all appearance preferences">
<RotateCcw className="h-4 w-4" />
Reset
</SettingsSecondaryButton>
</SettingsField>
</div>
);
}
@@ -11,6 +11,9 @@ import { SettingsActions, SettingsPrimaryButton, SettingsSecondaryButton } from
import { EnvironmentChecks } from './EnvironmentChecks';
import { DEPLOY_FEEDBACK_KEY } from '@/hooks/use-deploy-feedback-enabled';
import { COMPOSE_DIFF_PREVIEW_KEY } from '@/hooks/use-compose-diff-preview-enabled';
import { resetPreferenceDomain } from '@/lib/preferences/resetPreferences';
import { getUnsavedEpisode, subscribeToUnsaved } from '@/lib/preferences/preferenceEvents';
import { inspectQueue } from '@/lib/preferences/syncBus';
// Mirrors the backend DiagnosticsReport (services/DiagnosticsService.ts). Kept
// local because the frontend cannot import backend types.
@@ -30,10 +33,9 @@ interface DiagnosticsReport {
type Health = 'ok' | 'warn' | 'error';
// Browser-local display preferences cleared by "Reset interface preferences".
// The density key is internal to use-density; the other two are exported.
const DENSITY_KEY = 'sencho.appearance.density';
const INTERFACE_PREF_KEYS = [DENSITY_KEY, DEPLOY_FEEDBACK_KEY, COMPOSE_DIFF_PREVIEW_KEY];
// Browser-local workflow toggles cleared by "Reset interface preferences".
// Density used to be listed here; it now lives in the account-synced
// appearance document and is reset through that path instead.
const CLI_COMMANDS: Array<{ cmd: string; purpose: string }> = [
{ cmd: 'node dist/cli/resetMfa.js <username>', purpose: "Clear a user's two-factor enrolment" },
@@ -158,11 +160,67 @@ export function RecoverySection() {
}
};
const finishReset = () => {
toast.success('Interface preferences reset to defaults. Reloading...');
window.setTimeout(() => window.location.reload(), 600);
};
const resetInterface = () => {
try {
INTERFACE_PREF_KEYS.forEach(key => window.localStorage.removeItem(key));
toast.success('Interface preferences reset to defaults. Reloading...');
setTimeout(() => window.location.reload(), 600);
// Deploy-feedback and diff-preview are browser-local workflow
// toggles outside the preference domains; clear them as before.
window.localStorage.removeItem(DEPLOY_FEEDBACK_KEY);
window.localStorage.removeItem(COMPOSE_DIFF_PREVIEW_KEY);
// Appearance + navigation are account documents: reset both
// domains (optimistic defaults now, tombstone DELETEs through the
// sync bus). The reload happens only when no unsaved episode
// remains, so a partial reset never claims full success.
resetPreferenceDomain('appearance');
resetPreferenceDomain('navigation');
// Three settle signals race (unsaved transition, poller, hard
// stop); exactly one of them may finish the reset.
let settled = false;
const finishOnce = () => {
if (settled) return;
settled = true;
finishReset();
};
const stop = subscribeToUnsaved((episode) => {
if (episode !== null) return; // a failure already surfaced its own toast
stop();
finishOnce();
});
// Pure successes emit no unsaved transition, so polling is the
// settle signal: each tick checks both the episode and the sync
// bus queue state. A tick counts as clean only when no DELETE is
// queued or in flight, so the reload can never cancel one.
let cleanTicks = 0;
const settleTimer = window.setInterval(() => {
if (getUnsavedEpisode() !== null) {
window.clearInterval(settleTimer);
stop();
return; // a failure path owns the error surface
}
const busy = inspectQueue('appearance').settling || inspectQueue('navigation').settling;
cleanTicks = busy ? 0 : cleanTicks + 1;
if (cleanTicks >= 2) {
window.clearInterval(settleTimer);
stop();
finishOnce();
}
}, 300);
// Hard stop: never poll longer than the grace period; a still
// in-flight reset must not claim success, so say so instead of
// finishing silently.
window.setTimeout(() => {
window.clearInterval(settleTimer);
stop();
const busy = inspectQueue('appearance').settling || inspectQueue('navigation').settling;
if (getUnsavedEpisode() === null && !busy) finishOnce();
else if (!settled) {
toast.error('The reset is taking longer than expected and has not finished. Your preferences will keep retrying in the background.');
}
}, 4000);
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Could not reset interface preferences.');
}
@@ -238,10 +296,10 @@ export function RecoverySection() {
>
<SettingsField
label="Reset interface preferences"
helper="Restore density and editor display options on this browser to their defaults, then reload. Useful if a display setting wedges the layout."
helper="Restore every appearance and navigation preference to its default for your account, and the browser-local editor toggles for this browser, then reload. Useful if a display setting wedges the layout."
align="start"
>
<SettingsSecondaryButton onClick={resetInterface}>
<SettingsSecondaryButton onClick={resetInterface} aria-label="Reset interface preferences">
<RotateCcw className="h-4 w-4" />
Reset
</SettingsSecondaryButton>
@@ -27,6 +27,7 @@ import LazyBoundary from '../LazyBoundary';
import { SectionGate } from './SectionGate';
import type { NavDestination } from '@/lib/navigation/appNavRegistry';
import type { ActiveView } from '@/lib/router/routeTypes';
import { resetPreferenceDomain } from '@/lib/preferences/resetPreferences';
// Paid-tier sections are loaded on demand. SectionGate returns null for
// Community / unentitled operators before reaching the JSX that would mount
@@ -90,6 +91,8 @@ function renderSection({
<AppearanceSection
quickLinkCandidates={quickLinkCandidates}
defaultQuickLinkEligibility={defaultQuickLinkEligibility}
onResetAppearance={() => resetPreferenceDomain('appearance')}
onResetNavigation={() => resetPreferenceDomain('navigation')}
/>
);
case 'license': return <LicenseSection />;
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, act, renderHook } from '@testing-library/react';
import { AppearanceSection } from '../AppearanceSection';
import { useTheme } from '@/hooks/use-theme';
@@ -21,7 +21,7 @@ describe('AppearanceSection', () => {
beforeEach(() => resetTheme());
it('renders the four refresh sections above Theme', () => {
render(<AppearanceSection />);
render(<AppearanceSection onResetAppearance={() => {}} onResetNavigation={() => {}} />);
expect(screen.getByText('Visual style')).toBeTruthy();
expect(screen.getByText('Security visualization')).toBeTruthy();
expect(screen.getByText('Readability')).toBeTruthy();
@@ -29,7 +29,7 @@ describe('AppearanceSection', () => {
});
it('selecting the Calm card applies the calm resolution to <html>', () => {
render(<AppearanceSection />);
render(<AppearanceSection onResetAppearance={() => {}} onResetNavigation={() => {}} />);
fireEvent.click(screen.getByRole('button', { name: /Calm/i }));
expect(document.documentElement.dataset.headings).toBe('clean');
expect(document.documentElement.dataset.chartStyle).toBe('muted');
@@ -38,7 +38,7 @@ describe('AppearanceSection', () => {
});
it('Calm and Signature preset apply write reducedMotion; Effects alone does not', () => {
render(<AppearanceSection />);
render(<AppearanceSection onResetAppearance={() => {}} onResetNavigation={() => {}} />);
// Baseline Signature clears Motion.
expect(document.documentElement.dataset.motion).toBeUndefined();
@@ -66,7 +66,7 @@ describe('AppearanceSection', () => {
});
it('shows the constrained-graphics callout when Reduced motion is off, and hides it when on', () => {
render(<AppearanceSection />);
render(<AppearanceSection onResetAppearance={() => {}} onResetNavigation={() => {}} />);
expect(screen.getByText('Constrained graphics')).toBeTruthy();
// Reduced effects alone must not hide the Motion guidance.
@@ -85,14 +85,14 @@ describe('AppearanceSection', () => {
});
it('states that log chip color applies on multi-service or multi-container stacks', () => {
render(<AppearanceSection />);
render(<AppearanceSection onResetAppearance={() => {}} onResetNavigation={() => {}} />);
expect(
screen.getByText(/Applies to service chips on multi-service or multi-container stacks/i),
).toBeTruthy();
});
it('readability locks the header + chart controls and disables the glow slider', () => {
const { container } = render(<AppearanceSection />);
const { container } = render(<AppearanceSection onResetAppearance={() => {}} onResetNavigation={() => {}} />);
// Baseline: nothing reduced, so no slider is disabled.
expect(container.querySelectorAll('[data-disabled]').length).toBe(0);
expect(screen.getByRole('radiogroup', { name: 'Header style' }).getAttribute('aria-disabled')).toBeNull();
@@ -108,7 +108,7 @@ describe('AppearanceSection', () => {
});
it('reduced motion is independent of readability and toggles data-motion on <html>', () => {
render(<AppearanceSection />);
render(<AppearanceSection onResetAppearance={() => {}} onResetNavigation={() => {}} />);
const motion = () => screen.getByRole('switch', { name: 'Reduced motion' }) as HTMLButtonElement;
expect(document.documentElement.dataset.motion).toBeUndefined();
// Readability flattens effects but must not disable the motion toggle.
@@ -119,7 +119,7 @@ describe('AppearanceSection', () => {
});
it('readability also locks the Visual style cards and the Border brightness slider', () => {
const { container } = render(<AppearanceSection />);
const { container } = render(<AppearanceSection onResetAppearance={() => {}} onResetNavigation={() => {}} />);
const calmCard = () => screen.getByRole('button', { name: /readable default/i }) as HTMLButtonElement;
const sigCard = () => screen.getByRole('button', { name: /Today's look/i }) as HTMLButtonElement;
const borderLocked = () => !!container.querySelector('[aria-label="Border brightness"][data-disabled]');
@@ -137,7 +137,7 @@ describe('AppearanceSection', () => {
});
it('de-selects both visual-style cards when a custom sub-axis is chosen', () => {
render(<AppearanceSection />);
render(<AppearanceSection onResetAppearance={() => {}} onResetNavigation={() => {}} />);
// Baseline is Signature, so the Signature card reads selected.
expect(screen.getByRole('button', { name: /Today's look/i }).getAttribute('aria-pressed')).toBe('true');
// A custom chart palette (Heat) makes the trio match no preset.
@@ -147,7 +147,7 @@ describe('AppearanceSection', () => {
});
it('de-selects when only the header style diverges (not just the chart palette)', () => {
render(<AppearanceSection />);
render(<AppearanceSection onResetAppearance={() => {}} onResetNavigation={() => {}} />);
// Baseline Signature; flipping only Header style to Clean breaks the match.
fireEvent.click(screen.getByRole('radio', { name: 'Clean' }));
expect(screen.getByRole('button', { name: /Today's look/i }).getAttribute('aria-pressed')).toBe('false');
@@ -155,7 +155,7 @@ describe('AppearanceSection', () => {
});
it('reset to default restores Calm and locks while readability is on', () => {
render(<AppearanceSection />);
render(<AppearanceSection onResetAppearance={() => {}} onResetNavigation={() => {}} />);
fireEvent.click(screen.getByRole('radio', { name: 'Heat' }));
expect(document.documentElement.dataset.chartStyle).toBe('heat');
@@ -171,7 +171,7 @@ describe('AppearanceSection', () => {
it('shows Navigation style and mode-conditional controls', () => {
localStorage.clear();
render(<AppearanceSection />);
render(<AppearanceSection onResetAppearance={() => {}} onResetNavigation={() => {}} />);
expect(screen.getByText('Navigation')).toBeTruthy();
const navigationStyle = screen.getByRole('radiogroup', { name: 'Navigation style' });
expect(navigationStyle).toBeTruthy();
@@ -190,7 +190,7 @@ describe('AppearanceSection', () => {
it('offers only Compact launcher and Smart bar, with Compact first', () => {
localStorage.clear();
render(<AppearanceSection />);
render(<AppearanceSection onResetAppearance={() => {}} onResetNavigation={() => {}} />);
const options = screen.getAllByRole('radio', { name: /bar|launcher/i }).map((el) => el.textContent);
expect(options).toEqual(['Compact launcher', 'Smart bar']);
expect(screen.queryByRole('radio', { name: 'Classic bar' })).toBeNull();
@@ -199,13 +199,35 @@ describe('AppearanceSection', () => {
it('disables Reset to defaults while default eligibility has not settled', () => {
localStorage.clear();
render(<AppearanceSection quickLinkCandidates={[]} defaultQuickLinkEligibility={null} />);
render(<AppearanceSection quickLinkCandidates={[]} defaultQuickLinkEligibility={null} onResetAppearance={() => {}} onResetNavigation={() => {}} />);
expect((screen.getByRole('button', { name: 'Reset to defaults' }) as HTMLButtonElement).disabled).toBe(true);
});
it('enables Reset to defaults once default eligibility has settled', () => {
localStorage.clear();
render(<AppearanceSection quickLinkCandidates={[]} defaultQuickLinkEligibility={['dashboard']} />);
render(<AppearanceSection quickLinkCandidates={[]} defaultQuickLinkEligibility={['dashboard']} onResetAppearance={() => {}} onResetNavigation={() => {}} />);
expect((screen.getByRole('button', { name: 'Reset to defaults' }) as HTMLButtonElement).disabled).toBe(false);
});
it('the complete-domain reset buttons call the shared domain-reset handlers', () => {
const onResetAppearance = vi.fn();
const onResetNavigation = vi.fn();
render(<AppearanceSection onResetAppearance={onResetAppearance} onResetNavigation={onResetNavigation} />);
fireEvent.click(screen.getByRole('button', { name: 'Reset all appearance preferences' }));
expect(onResetAppearance).toHaveBeenCalledTimes(1);
expect(onResetNavigation).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'Reset all navigation preferences' }));
expect(onResetNavigation).toHaveBeenCalledTimes(1);
expect(onResetAppearance).toHaveBeenCalledTimes(1);
});
it('the account-synced footer and reset helpers state the account scope', () => {
render(<AppearanceSection onResetAppearance={() => {}} onResetNavigation={() => {}} />);
expect(screen.getByText(/saved to your account · every device picks it up on sign-in/i)).toBeTruthy();
expect(screen.getAllByText(/for your account on every device/i).length).toBe(2);
// The retired browser-local wording must not resurface.
expect(screen.queryByText(/this browser/i)).toBeNull();
});
});
@@ -0,0 +1,150 @@
/**
* RecoverySection's "Reset interface preferences" is the both-domains reset on
* top of the shared per-domain reset handler. These tests cover the
* both-domains reset wiring: both domains go through the shared per-domain
* reset handler, and the success toast + reload wait until both domain DELETEs
* settle (a partial reset never claims full success).
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
vi.mock('@/components/ui/toast-store', () => ({
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
}));
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { RecoverySection } from '../RecoverySection';
import { setCurrentSyncUser } from '@/lib/preferences/syncBus';
import { resetPreferenceSync } from '@/lib/preferences/preferenceEvents';
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
function jsonResponse(status: number, body: unknown): Response {
return {
ok: status >= 200 && status < 300,
status,
headers: new Headers(),
json: async () => body,
clone() { return this as Response; },
} as unknown as Response;
}
async function renderReadySection(): Promise<void> {
mockedFetch.mockImplementation(async (path: string) => {
if (String(path).includes('/diagnostics/environment')) {
return jsonResponse(200, { checks: [] });
}
return jsonResponse(200, {
version: '1.0.0',
database: { ok: true, integrity: 'ok', path: '', missingTables: [] },
encryptionKey: { present: true, valid: true },
docker: { reachable: true },
auth: { adminCount: 1, userCount: 1, mfaEnrolledCount: 0, ssoProviders: [] },
config: {},
});
});
render(<RecoverySection />);
await screen.findByText('Safe actions');
}
describe('RecoverySection: Reset interface preferences (both-domains reset)', () => {
beforeEach(() => {
mockedFetch.mockReset();
localStorage.clear();
setCurrentSyncUser(7);
// The toast mock is module-level and shared by both tests in this
// file; clear it so one test's calls cannot leak into the other's
// assertion.
vi.mocked(toast.success).mockClear();
vi.mocked(toast.error).mockClear();
resetPreferenceSync();
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
vi.useRealTimers();
});
it('resets both preference domains and reloads after both settle', async () => {
const reload = vi.fn();
Object.defineProperty(window, 'location', { value: { ...window.location, reload }, writable: true });
await renderReadySection();
// Both DELETEs succeed: no unsaved episode ever appears, so the
// settle listener fires when the queue drains. The reset's DELETE
// first GETs a baseline (no known revision in this bare-bus test);
// the reset enqueues only the DELETE, the tombstone stands until a
// post-reset user edit stages its own PUT behind it.
mockedFetch.mockImplementation(async (path: string, opts?: RequestInit) => {
if (String(path).includes('/diagnostics/environment')) return jsonResponse(200, { checks: [] });
if (String(path) === '/user-preferences' && (opts?.method ?? 'GET') === 'GET') {
return jsonResponse(200, {
preferences: {
appearance: { schemaVersion: 1, revision: 1, updatedAt: 1, data: {} },
navigation: { schemaVersion: 1, revision: 1, updatedAt: 1, data: {} },
},
});
}
if (opts?.method === 'DELETE') return jsonResponse(200, { domain: 'x', schemaVersion: 0, revision: 2, updatedAt: 1 });
return jsonResponse(200, { domain: 'x', schemaVersion: 1, revision: 2, updatedAt: 1 });
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Reset interface preferences' }));
// Drain the bus's async pumps (two DELETEs) inside act.
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
// Pure successes emit no unsaved-episode transition, so the success
// path is the 4s fallback (it settles when no failure appeared).
await act(async () => {
vi.advanceTimersByTime(4200);
});
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith('Interface preferences reset to defaults. Reloading...');
});
await waitFor(() => {
expect(reload).toHaveBeenCalled();
});
});
it('does not claim success while a domain still has unsaved writes', async () => {
const reload = vi.fn();
Object.defineProperty(window, 'location', { value: { ...window.location, reload }, writable: true });
await renderReadySection();
// No DELETE mock succeeds (requests fail) so an unsaved episode appears.
mockedFetch.mockImplementation(async (path: string) => {
if (String(path).includes('/diagnostics/environment')) return jsonResponse(200, { checks: [] });
return jsonResponse(500, { error: 'unavailable' });
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Reset interface preferences' }));
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
// Let the settle poller and the grace-period timer run out: the
// failure path owns the error surface, so the success toast and the
// reload must never fire.
await act(async () => {
vi.advanceTimersByTime(4200);
});
expect(toast.success).not.toHaveBeenCalled();
expect(reload).not.toHaveBeenCalled();
// Clear any episode the failure path left so other suites are unaffected.
resetPreferenceSync();
});
});
@@ -161,7 +161,7 @@ describe('StacksSection', () => {
describe('AppearanceSection no longer owns stack-workflow controls', () => {
it('does not render Deploy progress, Progress style, or Diff preview before save', () => {
render(<AppearanceSection />);
render(<AppearanceSection onResetAppearance={() => {}} onResetNavigation={() => {}} />);
expect(screen.queryByText('Deploy progress')).not.toBeInTheDocument();
expect(screen.queryByText('Progress style')).not.toBeInTheDocument();
expect(screen.queryByText('Diff preview before save')).not.toBeInTheDocument();
@@ -107,11 +107,11 @@ describe('settings registry', () => {
}
});
it('scopes the browser-local sections to the browser', () => {
// Appearance is the only remaining browser-local section. Stacks moved to
// node scope when Deploy Guardrails (backend settings) were added to it
// alongside the existing browser-local Workflow controls.
expect(SETTINGS_ITEMS.find(i => i.id === 'appearance')?.scope).toBe('browser');
it('scopes Appearance to the account and Stacks to the node', () => {
// Appearance persists per signed-in account on the server (account scope).
// Stacks moved to node scope when Deploy Guardrails (backend settings) were
// added to it alongside the existing browser-local Workflow controls.
expect(SETTINGS_ITEMS.find(i => i.id === 'appearance')?.scope).toBe('account');
expect(SETTINGS_ITEMS.find(i => i.id === 'stacks')?.scope).toBe('node');
});
@@ -129,6 +129,11 @@ describe('scopeLabel', () => {
keywords: [], tier: null, scope: 'global', ...over,
});
it('reads account for account-synced sections regardless of their group', () => {
expect(scopeLabel(item({ scope: 'account', group: 'personal' }))).toBe('account');
expect(scopeLabel(item({ scope: 'account', group: 'infrastructure' }))).toBe('account');
});
it('reads browser for browser-scoped sections regardless of their group', () => {
expect(scopeLabel(item({ scope: 'browser', group: 'personal' }))).toBe('browser');
expect(scopeLabel(item({ scope: 'browser', group: 'infrastructure' }))).toBe('browser');
+8 -7
View File
@@ -32,7 +32,7 @@ export const SETTINGS_GROUPS: readonly SettingsGroupMeta[] = [
];
export type TierGate = 'paid' | null;
export type Scope = 'global' | 'node' | 'browser';
export type Scope = 'global' | 'node' | 'browser' | 'account';
export interface SettingsItemMeta {
id: SectionId;
@@ -65,10 +65,10 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
id: 'appearance',
group: 'personal',
label: 'Appearance',
description: 'Visual style, readability, theme, accent, charts, display, and navigation preferences saved to this browser.',
keywords: ['theme', 'dim', 'oled', 'light', 'dark', 'accent', 'color', 'glow', 'border', 'contrast', 'density', 'comfortable', 'compact', 'spacing', 'display', 'calm', 'signature', 'readability', 'heading', 'chart', 'motion', 'effects', 'navigation', 'smart', 'launcher', 'quick links', 'topbar', 'top nav'],
description: 'Visual style, readability, theme, accent, charts, display, and navigation preferences saved to your account.',
keywords: ['theme', 'dim', 'oled', 'light', 'dark', 'accent', 'color', 'glow', 'border', 'contrast', 'density', 'comfortable', 'compact', 'spacing', 'display', 'calm', 'signature', 'readability', 'heading', 'chart', 'motion', 'effects', 'navigation', 'smart', 'launcher', 'quick links', 'topbar', 'top nav', 'sync', 'account'],
tier: null,
scope: 'browser',
scope: 'account',
},
// Access
{
@@ -351,15 +351,16 @@ export function isItemLocked(item: SettingsItemMeta, ctx: VisibilityContext): bo
}
/**
* The masthead SCOPE value for a non-node section. Browser-local sections
* (Appearance) persist to this browser's localStorage and read as
* browser regardless of their group; the signed-in Account is operator-scoped;
* The masthead SCOPE value for a non-node section. Account-synced sections
* (Appearance) persist per signed-in account on the server and read as
* account regardless of their group; the signed-in Account is operator-scoped;
* Access sections (license, users, sso, api-tokens) are instance-global, so
* they read as global like every other non-node group. Node-scoped sections
* render a NODE pill instead and never reach here.
*/
export function scopeLabel(item: SettingsItemMeta): string {
if (item.scope === 'browser') return 'browser';
if (item.scope === 'account') return 'account';
if (item.group === 'personal') return 'operator';
return 'global';
}
@@ -140,7 +140,7 @@ export function ThemeQuickSwitch({ onOpenAppearance }: ThemeQuickSwitchProps) {
{/* Footer */}
<div className="border-t border-card-border/60 px-[var(--density-row-x)] py-[var(--density-row-y)]">
<p className="font-mono text-[10px] leading-4 uppercase tracking-[0.14em] text-stat-subtitle/70">
Saved to this browser · fine-tune borders &amp; glow in{' '}
Saved to your account · fine-tune borders &amp; glow in{' '}
<button
type="button"
onClick={openAppearance}
@@ -0,0 +1,94 @@
/**
* Corrupt-row reconciliation through the real sync owner hook path (not just
* the bus primitives): a GET that returns a corrupt row must adopt the calm
* defaults locally (the pre-edit values never render) and repair the row with
* a conditional PUT carrying the corrupt row's revision, so a concurrent
* writer cannot be clobbered.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, act, cleanup } from '@testing-library/react';
const apiFetch = vi.fn();
vi.mock('@/lib/api', () => ({ apiFetch: (path: string, opts?: unknown) => apiFetch(path, opts) }));
const authState = { user: null as { userId: number } | null, appStatus: 'loading' as 'loading' | 'authenticated' | 'unauthenticated' };
vi.mock('@/context/AuthContext', () => ({
useAuth: () => authState,
}));
import { useUserPreferencesSync } from '../useUserPreferencesSync';
import { currentThemeState } from '@/hooks/use-theme';
function jsonResponse(status: number, body: unknown): Response {
return {
ok: status >= 200 && status < 300,
status,
headers: new Headers(),
json: async () => body,
clone() { return this as Response; },
} as unknown as Response;
}
function recordedCalls(): Array<{ method: string; path: string; body: Record<string, unknown> | undefined }> {
return apiFetch.mock.calls.map(([path, opts]) => ({
method: (opts as RequestInit | undefined)?.method ?? 'GET',
path: path as string,
body: JSON.parse(((opts as RequestInit | undefined)?.body as string | undefined) ?? 'null'),
}));
}
function SyncOwner(): null {
useUserPreferencesSync();
return null;
}
describe('useUserPreferencesSync: corrupt-row repair through the hook path', () => {
beforeEach(() => {
apiFetch.mockReset();
localStorage.clear();
authState.user = { userId: 9 };
authState.appStatus = 'authenticated';
});
afterEach(() => {
cleanup();
});
it('hydrates defaults and sends a conditional repair PUT on the corrupt revision', async () => {
// Leave a stale cached theme so the assertion proves the corrupt row
// (not the cache) decides what renders.
localStorage.setItem('sencho.appearance.theme', JSON.stringify({ theme: 'oled' }));
apiFetch.mockImplementation(async (path: string, opts?: RequestInit) => {
if (String(path) === '/user-preferences' && (opts?.method ?? 'GET') === 'GET') {
return jsonResponse(200, {
preferences: {
appearance: { corrupt: true, schemaVersion: 1, revision: 6, updatedAt: 1 },
navigation: { corrupt: true, schemaVersion: 1, revision: 6, updatedAt: 1 },
},
});
}
return jsonResponse(200, { domain: 'x', schemaVersion: 1, revision: 7, updatedAt: 1 });
});
render(<SyncOwner />);
await act(async () => {
await vi.waitFor(() => {
expect(recordedCalls().some((c) => c.method === 'PUT')).toBe(true);
});
});
const put = recordedCalls().find((c) => c.method === 'PUT');
expect(put).toBeDefined();
expect(put?.path).toBe('/user-preferences/appearance');
// Conditional on the corrupt row's own revision: never an unguarded write.
expect(put?.body).toMatchObject({ expectedRevision: 6, theme: 'dim' });
// Defaults are live locally (the module state the UI renders from); the
// stale cache key is wiped by the ownership claim because this test's
// browser has no prior owner marker.
expect(currentThemeState().theme).toBe('dim');
// No migration was attempted (a corrupt row is repaired, not re-created).
expect(recordedCalls().filter((c) => c.method === 'POST')).toHaveLength(0);
});
});
@@ -0,0 +1,289 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, act, cleanup } from '@testing-library/react';
const apiFetch = vi.fn();
vi.mock('@/lib/api', () => ({ apiFetch: (path: string, opts?: unknown) => apiFetch(path, opts) }));
const authState = { user: null as { userId: number } | null, appStatus: 'loading' as 'loading' | 'authenticated' | 'unauthenticated' };
vi.mock('@/context/AuthContext', () => ({
useAuth: () => authState,
}));
import { useUserPreferencesSync } from '../useUserPreferencesSync';
import { applyThemeState } from '@/hooks/use-theme';
import {
bumpGeneration,
currentGeneration,
getUnsavedEpisode,
notifyPreferenceWrite,
resetPreferenceSync,
} from '@/lib/preferences/preferenceEvents';
import { setCurrentSyncUser, setHydratingDomains } from '@/lib/preferences/syncBus';
interface MockResponse {
ok: boolean;
status: number;
headers: { get: (name: string) => string | null };
json: () => Promise<unknown>;
clone: () => MockResponse;
}
function jsonResponse(status: number, body: unknown): MockResponse {
const response: MockResponse = {
ok: status >= 200 && status < 300,
status,
headers: { get: () => null },
json: async () => body,
clone: () => response,
};
return response;
}
function recordedCalls(): Array<{ method: string; path: string; body: Record<string, unknown> | undefined }> {
return apiFetch.mock.calls.map(([path, opts]) => ({
method: (opts as RequestInit | undefined)?.method ?? 'GET',
path: path as string,
body: JSON.parse(((opts as RequestInit | undefined)?.body as string | undefined) ?? 'null'),
}));
}
function SyncOwner(): null {
useUserPreferencesSync();
return null;
}
/** Drain microtasks and the 400ms write debounce without fake timers. */
async function flushDebounce(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 450));
}
describe('useUserPreferencesSync: dirty-field merge against a late hydration GET', () => {
beforeEach(() => {
apiFetch.mockReset();
localStorage.clear();
// use-theme keeps module state across tests in this file, and its setState
// skips no-op writes: without forcing the state back to the app default,
// a test whose edit matches the previous test's final theme would never
// re-write the raw cache this file asserts on.
applyThemeState({ theme: 'dim' });
setCurrentSyncUser(9);
authState.user = { userId: 9 };
authState.appStatus = 'authenticated';
setHydratingDomains(new Set());
resetPreferenceSync();
});
afterEach(() => {
cleanup();
});
it('a user edit landing before the hydration GET response survives the merge and the PUT carries its local value', async () => {
// Server truth: OLED theme. The user has already switched to light while
// the GET was in flight; the merge must keep the server's untouched
// fields but re-apply the dirty theme on top.
let resolveGet: ((r: MockResponse) => void) | undefined;
apiFetch.mockImplementation(async (path: string, opts?: RequestInit) => {
if (String(path) === '/user-preferences' && (opts?.method ?? 'GET') === 'GET') {
return new Promise<MockResponse>((resolve) => { resolveGet = resolve; });
}
if (opts?.method === 'PUT') {
return jsonResponse(200, { domain: 'appearance', schemaVersion: 1, revision: 2, updatedAt: 1 });
}
return jsonResponse(200, { preferences: {} });
});
render(<SyncOwner />);
// The hydration GET is hanging; the user edits the theme meanwhile.
applyThemeState({ theme: 'light' });
notifyPreferenceWrite('appearance', ['theme']);
// The queued PUT fires after the debounce while the GET is still pending;
// the known revision is null, so the bus GETs a baseline first. Let those
// baseline calls go out now so only the hydration GET remains pending.
await act(async () => {
await flushDebounce();
});
apiFetch.mockClear();
// Now the hydration GET resolves with the server document.
await act(async () => {
resolveGet?.(jsonResponse(200, {
preferences: {
appearance: {
schemaVersion: 1, revision: 1, updatedAt: 1,
data: {
theme: 'oled', accent: 'cyan', uiFont: 'Geist', monoFont: 'Geist Mono',
visualStyle: 'calm', headingStyle: 'clean', chartStyle: 'muted',
density: 'comfortable', logChipColorMode: 'unified',
borderBoost: 0, glow: 0.16, contrast: 0, typeScale: 1,
reducedEffects: true, reducedMotion: true, readability: false,
},
},
navigation: null,
},
}));
await vi.waitFor(() => {
expect(recordedCalls().some((c) => c.method === 'PUT')).toBe(true);
});
});
const calls = recordedCalls();
const put = calls.find((c) => c.method === 'PUT');
expect(put).toBeDefined();
// The merged PUT carries the dirty field's LOCAL value (light), not the
// server's (oled); untouched fields keep the server values.
expect(put?.body).toMatchObject({ expectedRevision: 1, theme: 'light', accent: 'cyan', visualStyle: 'calm' });
// Local state keeps the user's edit too.
expect(JSON.parse(localStorage.getItem('sencho.appearance.theme') as string)).toMatchObject({ theme: 'light' });
});
it('a 409 on the merged PUT reconciles against the returned current and re-PUTs on its revision', async () => {
// Same merge scenario as above, but the server's merged PUT conflicts:
// another writer advanced the row to revision 2. The owner must GET the
// fresh state (reconcile), then re-PUT the merged document conditionally
// on revision 2, still carrying the dirty local theme.
let resolveGet: ((r: MockResponse) => void) | undefined;
const serverDoc = {
theme: 'oled', accent: 'cyan', uiFont: 'Geist', monoFont: 'Geist Mono',
visualStyle: 'calm', headingStyle: 'clean', chartStyle: 'muted',
density: 'comfortable', logChipColorMode: 'unified',
borderBoost: 0, glow: 0.16, contrast: 0, typeScale: 1,
reducedEffects: true, reducedMotion: true, readability: false,
};
apiFetch.mockImplementation(async (path: string, opts?: RequestInit) => {
if (String(path) === '/user-preferences' && (opts?.method ?? 'GET') === 'GET') {
return new Promise<MockResponse>((resolve) => { resolveGet = resolve; });
}
if (opts?.method === 'PUT') {
// Only the merged PUT (expectedRevision 1) conflicts; the reconciled
// retry on revision 2 succeeds, so exactly one conflict is exercised.
const rev = JSON.parse((opts?.body as string)).expectedRevision;
if (rev === 1) {
return jsonResponse(409, { error: 'CONFLICT', current: { schemaVersion: 1, revision: 2, updatedAt: 1, data: serverDoc } });
}
return jsonResponse(200, { domain: 'appearance', schemaVersion: 1, revision: 3, updatedAt: 2 });
}
return jsonResponse(200, { preferences: { appearance: { schemaVersion: 1, revision: 2, updatedAt: 1, data: serverDoc }, navigation: null } });
});
render(<SyncOwner />);
applyThemeState({ theme: 'light' });
notifyPreferenceWrite('appearance', ['theme']);
await act(async () => {
await flushDebounce();
});
apiFetch.mockClear();
// The hydration GET resolves; the queued PUT then goes out and 409s.
await act(async () => {
resolveGet?.(jsonResponse(200, {
preferences: { appearance: { schemaVersion: 1, revision: 1, updatedAt: 1, data: serverDoc }, navigation: null },
}));
await vi.waitFor(() => {
expect(recordedCalls().some((c) => c.method === 'PUT')).toBe(true);
});
});
// One conflict is not a failure: the owner reconciled instead.
expect(getUnsavedEpisode()).toBeNull();
await act(async () => {
await vi.waitFor(() => {
const puts = recordedCalls().filter((c) => c.method === 'PUT');
expect(puts.length).toBe(2);
// The retried PUT is conditional on the conflicted row's revision.
expect(puts[1].body).toMatchObject({ expectedRevision: 2, theme: 'light', accent: 'cyan' });
});
});
expect(getUnsavedEpisode()).toBeNull();
});
it('two consecutive 409s stop retrying and surface the unsaved episode with dirty fields kept', async () => {
let resolveGet: ((r: MockResponse) => void) | undefined;
const serverDoc = {
theme: 'oled', accent: 'cyan', uiFont: 'Geist', monoFont: 'Geist Mono',
visualStyle: 'calm', headingStyle: 'clean', chartStyle: 'muted',
density: 'comfortable', logChipColorMode: 'unified',
borderBoost: 0, glow: 0.16, contrast: 0, typeScale: 1,
reducedEffects: true, reducedMotion: true, readability: false,
};
apiFetch.mockImplementation(async (path: string, opts?: RequestInit) => {
if (String(path) === '/user-preferences' && (opts?.method ?? 'GET') === 'GET') {
return new Promise<MockResponse>((resolve) => { resolveGet = resolve; });
}
if (opts?.method === 'PUT') {
return jsonResponse(409, { error: 'CONFLICT', current: { schemaVersion: 1, revision: 2, updatedAt: 1, data: serverDoc } });
}
return jsonResponse(200, { preferences: { appearance: { schemaVersion: 1, revision: 2, updatedAt: 1, data: serverDoc }, navigation: null } });
});
render(<SyncOwner />);
applyThemeState({ theme: 'light' });
notifyPreferenceWrite('appearance', ['theme']);
await act(async () => {
await flushDebounce();
});
apiFetch.mockClear();
await act(async () => {
resolveGet?.(jsonResponse(200, {
preferences: { appearance: { schemaVersion: 1, revision: 1, updatedAt: 1, data: serverDoc }, navigation: null },
}));
await vi.waitFor(() => {
// Exactly two PUT attempts: the second conflict stops the loop and
// surfaces the failure instead of retrying forever.
expect(recordedCalls().filter((c) => c.method === 'PUT').length).toBe(2);
});
});
expect(getUnsavedEpisode()).not.toBeNull();
// The dirty field was re-marked so a later reconciliation re-applies it;
// the local cache still holds the user's edit.
expect(JSON.parse(localStorage.getItem('sencho.appearance.theme') as string)).toMatchObject({ theme: 'light' });
});
it('an identity transition before the hydration GET resolves discards the in-flight result', async () => {
// The GET hangs past a logout: its response must be dropped (no merge,
// no PUT) so the next account's hydration is never polluted.
let resolveGet: ((r: MockResponse) => void) | undefined;
apiFetch.mockImplementation(async (path: string, opts?: RequestInit) => {
if (String(path) === '/user-preferences' && (opts?.method ?? 'GET') === 'GET') {
return new Promise<MockResponse>((resolve) => { resolveGet = resolve; });
}
return jsonResponse(200, { preferences: { appearance: null, navigation: null } });
});
render(<SyncOwner />);
apiFetch.mockClear();
const generationBefore = currentGeneration();
act(() => {
bumpGeneration(); // logout path
});
await act(async () => {
resolveGet?.(jsonResponse(200, {
preferences: {
appearance: { schemaVersion: 1, revision: 1, updatedAt: 1, data: serverDocFor() },
navigation: null,
},
}));
// Wait out a real tick so a missing generation guard would have had
// time to dispatch the follow-up PUT.
await vi.waitFor(() => {
expect(recordedCalls()).toHaveLength(0);
});
});
expect(recordedCalls()).toHaveLength(0);
expect(currentGeneration()).toBeGreaterThan(generationBefore);
});
});
/** A full appearance server document for response bodies. */
function serverDocFor(): Record<string, unknown> {
return {
theme: 'oled', accent: 'cyan', uiFont: 'Geist', monoFont: 'Geist Mono',
visualStyle: 'calm', headingStyle: 'clean', chartStyle: 'muted',
density: 'comfortable', logChipColorMode: 'unified',
borderBoost: 0, glow: 0.16, contrast: 0, typeScale: 1,
reducedEffects: true, reducedMotion: true, readability: false,
};
}
@@ -0,0 +1,197 @@
/**
* Identity guards at the hook level. The sync owner must never let one
* account's queued edits, revisions, or cached values leak into another
* account's session:
* - A different account claims the browser: the cache is wiped and the owner
* marker is re-stamped before hydration.
* - A failed write keeps its unsaved episode only for the account that
* captured it; an identity transition clears the whole bus (queue,
* failures, known revisions) so a Retry can never replay under a new
* account.
* - While auth is resolving (loading), the owner touches nothing: a normal
* boot must not erase cached values.
* - A resolved-unauthenticated state wipes the cache so a later login never
* renders the previous account's values.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, act, cleanup } from '@testing-library/react';
const apiFetch = vi.fn();
vi.mock('@/lib/api', () => ({ apiFetch: (path: string, opts?: unknown) => apiFetch(path, opts) }));
const authState = { user: null as { userId: number } | null, appStatus: 'loading' as 'loading' | 'authenticated' | 'unauthenticated' };
vi.mock('@/context/AuthContext', () => ({
useAuth: () => authState,
}));
import { useUserPreferencesSync } from '../useUserPreferencesSync';
import {
bumpGeneration,
resetPreferenceSync,
subscribeToUnsaved,
getUnsavedEpisode,
currentGeneration,
} from '@/lib/preferences/preferenceEvents';
import {
inspectQueue,
retryDomain,
setCurrentSyncUser,
setHydratingDomains,
queueReset,
} from '@/lib/preferences/syncBus';
import { PREFERENCES_OWNER_KEY } from '@/lib/preferences/preferencesDocuments';
interface MockResponse {
ok: boolean;
status: number;
headers: { get: (name: string) => string | null };
json: () => Promise<unknown>;
clone: () => MockResponse;
}
function jsonResponse(status: number, body: unknown): MockResponse {
const response: MockResponse = {
ok: status >= 200 && status < 300,
status,
headers: { get: () => null },
json: async () => body,
clone: () => response,
};
return response;
}
function SyncOwner(): null {
useUserPreferencesSync();
return null;
}
describe('useUserPreferencesSync: identity guards', () => {
beforeEach(() => {
apiFetch.mockReset();
localStorage.clear();
setCurrentSyncUser(null);
authState.user = null;
authState.appStatus = 'loading';
setHydratingDomains(new Set());
resetPreferenceSync();
});
afterEach(() => {
cleanup();
});
it('a different account claims the browser: cache wiped, marker re-stamped, prior queue dropped', async () => {
// Account 7 left cached values and a queued reset in the bus.
localStorage.setItem('sencho.appearance.theme', JSON.stringify({ theme: 'oled' }));
localStorage.setItem(PREFERENCES_OWNER_KEY, JSON.stringify({ userId: 7, schema: 1 }));
setCurrentSyncUser(7);
queueReset('appearance');
// Account 3 signs in; hydration for account 3 sees the (empty) server.
apiFetch.mockImplementation(async () => jsonResponse(200, {
preferences: { appearance: null, navigation: null },
}));
authState.user = { userId: 3 };
authState.appStatus = 'authenticated';
render(<SyncOwner />);
await act(async () => {
await vi.waitFor(() => {
expect(apiFetch.mock.calls.some(([path]) => String(path) === '/user-preferences')).toBe(true);
});
});
// The stale cache was wiped before hydration; the marker now names 3.
expect(localStorage.getItem('sencho.appearance.theme')).toBeNull();
expect(JSON.parse(localStorage.getItem(PREFERENCES_OWNER_KEY) as string)).toMatchObject({ userId: 3 });
// The old account's queued operation was discarded by the queue reset:
// no DELETE may fire for appearance under the new account.
const calls = apiFetch.mock.calls.filter(([, opts]) => (opts as RequestInit | undefined)?.method === 'DELETE');
expect(calls).toHaveLength(0);
// Hydration ran for the new account (a GET went out).
expect(apiFetch.mock.calls.some(([path]) => String(path) === '/user-preferences')).toBe(true);
});
it('a failed write of the old account cannot be retried after the identity transition', async () => {
// Account 7 queues a reset; the DELETE fails (server 500), leaving a
// failed operation and an unsaved episode.
setCurrentSyncUser(7);
authState.user = { userId: 7 };
authState.appStatus = 'authenticated';
apiFetch.mockImplementation(async (_path: string, opts?: RequestInit) => {
if ((opts?.method ?? 'GET') === 'DELETE') return jsonResponse(500, { error: 'unavailable' });
return jsonResponse(200, { preferences: { appearance: null, navigation: null } });
});
const seen: Array<unknown> = [];
const stop = subscribeToUnsaved((episode) => seen.push(episode));
render(<SyncOwner />);
await act(async () => {
await vi.waitFor(() => {
expect(apiFetch.mock.calls.some(([path]) => String(path) === '/user-preferences')).toBe(true);
});
});
// The hydration itself succeeded (absent rows); the reset DELETE queued
// during mount drains separately, so wait for its failure to settle.
queueReset('appearance');
await act(async () => {
await vi.waitFor(() => {
expect(inspectQueue('appearance').failed).toBe('reset');
expect(getUnsavedEpisode()).not.toBeNull();
});
});
// The account switches (logout bumps the generation and resets the bus;
// this is exactly what AuthContext.noteIdentityTransition does).
apiFetch.mockClear();
act(() => {
bumpGeneration();
resetPreferenceSync();
});
// The failed operation, its episode, and the queue are all gone: a Retry
// action from a stale toast has nothing to replay.
expect(inspectQueue('appearance')).toEqual({ kind: null, failed: null, settling: false });
expect(getUnsavedEpisode()).toBeNull();
stop();
// Even a direct retry call after the transition must never replay the
// old failed DELETE: the failed operation is gone, so the most the retry
// can do is delegate to the reconcile hook (a fresh GET for whatever
// account is current now), never re-send the tombstone.
apiFetch.mockImplementation(async () => jsonResponse(200, { preferences: { appearance: null, navigation: null } }));
retryDomain('appearance');
const deletes = apiFetch.mock.calls.filter(([, opts]) => (opts as RequestInit | undefined)?.method === 'DELETE');
expect(deletes).toHaveLength(0);
});
it('auth loading touches nothing: a normal boot does not wipe the cache', () => {
localStorage.setItem('sencho.appearance.theme', JSON.stringify({ theme: 'oled' }));
localStorage.setItem(PREFERENCES_OWNER_KEY, JSON.stringify({ userId: 7, schema: 1 }));
// appStatus 'loading' with a resolved user id is the mid-boot state.
authState.user = { userId: 7 };
authState.appStatus = 'loading';
render(<SyncOwner />);
expect(localStorage.getItem('sencho.appearance.theme')).not.toBeNull();
expect(JSON.parse(localStorage.getItem(PREFERENCES_OWNER_KEY) as string)).toMatchObject({ userId: 7 });
expect(apiFetch).not.toHaveBeenCalled();
});
it('resolved-unauthenticated wipes the cache and bumps the generation', () => {
localStorage.setItem('sencho.appearance.theme', JSON.stringify({ theme: 'oled' }));
localStorage.setItem(PREFERENCES_OWNER_KEY, JSON.stringify({ userId: 7, schema: 1 }));
const generationBefore = currentGeneration();
authState.user = null;
authState.appStatus = 'unauthenticated';
render(<SyncOwner />);
expect(localStorage.getItem('sencho.appearance.theme')).toBeNull();
expect(localStorage.getItem(PREFERENCES_OWNER_KEY)).toBeNull();
expect(currentGeneration()).toBeGreaterThan(generationBefore);
expect(apiFetch).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,173 @@
/**
* Remote-reset precedence between two independent clients. The local
* sequence counter orders only this client's own queued operations; a reset
* performed in ANOTHER browser is ordered by the observable server revision
* in the GET envelope. A pending edit based on an older revision must be
* DISCARDED (no resurrect PUT) when reconciliation observes the tombstone;
* only an edit made after adopting the tombstone survives as a conditional
* PUT on the tombstone's revision.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, act, cleanup } from '@testing-library/react';
const apiFetch = vi.fn();
vi.mock('@/lib/api', () => ({ apiFetch: (path: string, opts?: unknown) => apiFetch(path, opts) }));
const authState = { user: null as { userId: number } | null, appStatus: 'loading' as 'loading' | 'authenticated' | 'unauthenticated' };
vi.mock('@/context/AuthContext', () => ({
useAuth: () => authState,
}));
import { useUserPreferencesSync } from '../useUserPreferencesSync';
import { applyThemeState } from '@/hooks/use-theme';
import {
adoptKnownRevision,
inspectQueue,
setHydratingDomains,
setCurrentSyncUser,
} from '@/lib/preferences/syncBus';
import { notifyPreferenceWrite } from '@/lib/preferences/preferenceEvents';
interface MockResponse {
ok: boolean;
status: number;
headers: { get: (name: string) => string | null };
json: () => Promise<unknown>;
clone: () => MockResponse;
}
function jsonResponse(status: number, body: unknown): MockResponse {
const response: MockResponse = {
ok: status >= 200 && status < 300,
status,
headers: { get: () => null },
json: async () => body,
clone: () => response,
};
return response;
}
function recordedCalls(): Array<{ method: string; path: string; body: Record<string, unknown> | undefined }> {
return apiFetch.mock.calls.map(([path, opts]) => ({
method: (opts as RequestInit | undefined)?.method ?? 'GET',
path: path as string,
body: JSON.parse(((opts as RequestInit | undefined)?.body as string | undefined) ?? 'null'),
}));
}
function SyncOwner(): null {
useUserPreferencesSync();
return null;
}
/** Drain the 400ms write debounce without fake timers: real waits inside act. */
async function flushDebounce(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 450));
}
/** Simulate client A: an edit enqueued, then the adoption of revision 2. */
function editThenAdoptStaleBaseline(): void {
applyThemeState({ theme: 'oled' });
notifyPreferenceWrite('appearance', ['theme']);
adoptKnownRevision('appearance', 2);
}
describe('useUserPreferencesSync: remote-reset precedence (two clients)', () => {
beforeEach(() => {
apiFetch.mockReset();
localStorage.clear();
setCurrentSyncUser(9);
authState.user = { userId: 9 };
authState.appStatus = 'authenticated';
setHydratingDomains(new Set());
});
afterEach(() => {
cleanup();
});
it('discards a pending edit based on an older revision when a remote tombstone is observed', async () => {
// Client A edits theme while offline (no server round trip yet), then
// boots: its GET observes that client B reset the domain at revision 5
// (schemaVersion 0 tombstone). The stale edit must be discarded.
editThenAdoptStaleBaseline();
apiFetch.mockImplementation(async (path: string, opts?: RequestInit) => {
if (String(path) === '/user-preferences' && (opts?.method ?? 'GET') === 'GET') {
return jsonResponse(200, {
preferences: {
appearance: { schemaVersion: 0, revision: 5, updatedAt: 1 },
navigation: null,
},
});
}
return jsonResponse(200, { domain: 'appearance', schemaVersion: 1, revision: 6, updatedAt: 1 });
});
render(<SyncOwner />);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
// No PUT of the stale pre-reset values may have gone out, and no
// migrate either (a tombstone blocks migration).
const calls = recordedCalls();
expect(calls.filter((c) => c.method === 'PUT')).toHaveLength(0);
expect(calls.filter((c) => c.method === 'POST')).toHaveLength(0);
// The GET happened exactly once (the hydration fetch).
expect(calls.filter((c) => c.method === 'GET')).toHaveLength(1);
// The queue must be clear: the stale edit was discarded, not parked.
expect(inspectQueue('appearance')).toEqual({ kind: null, failed: null, settling: false });
// The tombstone was adopted: hydration applied defaults.
expect(JSON.parse(localStorage.getItem('sencho.appearance.theme') as string)).toMatchObject({ theme: 'dim' });
});
it('an edit made after adopting the tombstone survives as a conditional PUT on the tombstone revision', async () => {
// Boot against the tombstone first (clean adoption), then edit.
apiFetch.mockImplementation(async (path: string, opts?: RequestInit) => {
if (String(path) === '/user-preferences' && (opts?.method ?? 'GET') === 'GET') {
return jsonResponse(200, {
preferences: {
appearance: { schemaVersion: 0, revision: 5, updatedAt: 1 },
navigation: null,
},
});
}
if (opts?.method === 'PUT') return jsonResponse(200, { domain: 'appearance', schemaVersion: 1, revision: 6, updatedAt: 1 });
return jsonResponse(200, { domain: 'appearance', schemaVersion: 1, revision: 6, updatedAt: 1 });
});
render(<SyncOwner />);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
expect(inspectQueue('appearance')).toEqual({ kind: null, failed: null, settling: false });
// The user now edits AFTER the tombstone was adopted (revision 5 known).
apiFetch.mockClear();
applyThemeState({ theme: 'oled' });
notifyPreferenceWrite('appearance', ['theme']);
await act(async () => {
await flushDebounce();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
const put = recordedCalls().find((c) => c.method === 'PUT');
expect(put).toBeDefined();
// Conditional on the tombstone's revision (5): an unconditional or
// stale-baselined write would 409 on the server.
expect(put?.body).toMatchObject({ expectedRevision: 5, theme: 'oled' });
});
});