mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-10 01:15:55 +00:00
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:
+136
@@ -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');
|
||||
|
||||
@@ -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 & glow in{' '}
|
||||
Saved to your account · fine-tune borders & 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' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user