Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -203,17 +203,25 @@ export function NotificationSettingsScreen({ scope }: Readonly<{ scope: string }
// iOS IME inset and focused-field auto-reveal (AGENTS.md: input screens).
automaticallyAdjustKeyboardInsets
className="flex-1"
contentContainerClassName="px-6 gap-6 pt-4"
// The personal scope has no section heading between its two switches, so
// the gap between the groups has to match the space above the first one,
// or the larger break reads as a heading that went missing. The
// organization scope keeps the standard gap, next to its headings. The
// permission note above the groups only renders for a scope that cannot
// manage, which the personal scope never is.
contentContainerClassName={cn('px-6 pt-4', personal ? 'gap-4' : 'gap-6')}
Comment thread
iscekic marked this conversation as resolved.
Resolved
>
{!canManage && (
<Text className="text-center text-xs text-muted-foreground">
{t('securityAgent.sla.permissionNote')}
</Text>
)}
<View className="gap-3">
<Text variant="small" className="uppercase tracking-wide text-muted-foreground">
{t('securityAgent.notifications.newFindingSection')}
</Text>
{!personal && (
<Text variant="small" className="uppercase tracking-wide text-muted-foreground">
{t('securityAgent.notifications.newFindingSection')}
</Text>
)}
<View className="gap-2">
<ToggleRow
title={
Expand Down Expand Up @@ -250,9 +258,11 @@ export function NotificationSettingsScreen({ scope }: Readonly<{ scope: string }
</View>

<View className="gap-3">
<Text variant="small" className="uppercase tracking-wide text-muted-foreground">
{t('securityAgent.notifications.slaSection')}
</Text>
{!personal && (
<Text variant="small" className="uppercase tracking-wide text-muted-foreground">
{t('securityAgent.notifications.slaSection')}
</Text>
)}
<View className="gap-2">
<ToggleRow
title={
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
// The personal notification settings screen titles each of its two switches
// after the notification it turns on ("Email me about new findings", "Email me
// about SLA warnings"), so a section heading above a single switch only restates
// it. The organization screen titles both switches "Email organization owners",
// so a heading is the only thing that tells the two of them apart.

import { createElement } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { i18n } from '@/i18n';
import { act, TestRenderer } from '@/test/renderer';

import { NotificationSettingsScreen } from './notification-settings-screen';

vi.mock('expo-crypto', () => ({ randomUUID: () => '00000000-0000-4000-8000-000000000000' }));
vi.mock('expo-haptics', () => ({ selectionAsync: vi.fn() }));
vi.mock('react-native', () => ({
View: 'View',
ScrollView: 'ScrollView',
TextInput: 'TextInput',
Pressable: 'Pressable',
Switch: 'Switch',
Platform: { OS: 'android' },
useColorScheme: () => 'light',
Alert: { alert: vi.fn() },
I18nManager: { isRTL: false },
}));
vi.mock('expo-router', () => ({
useRouter: () => ({ back: vi.fn(), replace: vi.fn(), push: vi.fn() }),
useNavigation: () => ({ dispatch: vi.fn(), goBack: vi.fn() }),
usePreventRemove: () => undefined,
}));
vi.mock('sonner-native', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock('@/components/ui/icons', () => ({ Check: 'Check' }));
vi.mock('@/lib/hooks/use-theme-colors', () => ({
useThemeColors: () => ({
mutedForeground: '#000',
primaryForeground: '#fff',
primary: '#00f',
}),
}));
vi.mock('@/lib/hooks/use-security-agent', () => ({
useSecurityAgentCapability: () => ({
canManage: true,
status: 'allowed',
isLoading: false,
isFetching: false,
isError: false,
refetch: vi.fn(),
}),
useSecurityAgentConfig: () => ({
data: {
isEnabled: true,
newFindingNotificationsEnabled: true,
newFindingNotificationMinSeverity: 'high',
slaNotificationsEnabled: true,
slaNotificationMinSeverity: 'medium',
slaNotificationWarningDays: 7,
},
isLoading: false,
isError: false,
refetch: vi.fn(),
}),
useSaveSecurityAgentConfig: () => ({ mutateAsync: vi.fn(), isPending: false }),
useTrackSecurityAgentInteraction: () => ({ mutate: vi.fn() }),
}));
vi.mock('@rn-primitives/dialog', () => ({
Action: 'AlertDialog.Action',
Cancel: 'AlertDialog.Cancel',
Content: 'AlertDialog.Content',
Description: 'AlertDialog.Description',
Overlay: 'AlertDialog.Overlay',
Portal: 'AlertDialog.Portal',
Root: 'AlertDialog.Root',
Title: 'AlertDialog.Title',
}));
vi.mock('@/components/destructive-confirm-dialog', () => ({
DestructiveConfirmDialog: () => null,
}));
vi.mock('@/components/ui/input', () => ({ Input: 'Input' }));
vi.mock('@/components/ui/skeleton', () => ({ Skeleton: () => null }));
vi.mock('@/components/ui/text', () => ({ Text: 'Text' }));
vi.mock('@/components/ui/radio-group', () => ({
RadioGroup: (props: { children?: unknown }) => props.children ?? null,
radioItemA11y: ({
label,
checked,
disabled,
}: {
label: string;
checked: boolean;
disabled: boolean;
}) => ({
accessibilityRole: 'radio',
accessibilityLabel: label,
accessibilityState: { checked, disabled, busy: false },
}),
}));
vi.mock('@/components/screen-header', () => ({ ScreenHeader: () => null }));
vi.mock('@/components/platform-error-screen', () => ({
PlatformErrorScreen: 'PlatformErrorScreen',
}));
vi.mock('@/components/security-agent/settings-save-button', () => ({
SettingsSaveButton: () => null,
}));
vi.mock('@/components/tab-screen', () => ({ TabScreenScrollView: 'TabScreenScrollView' }));

const PERSONAL_SCOPE = 'personal';
const ORGANIZATION_SCOPE = '00000000-0000-4000-8000-000000000001';

let renderer: TestRenderer.ReactTestRenderer | undefined = undefined;

afterEach(() => {
act(() => {
renderer?.unmount();
});
renderer = undefined;
});

function mount(scope: string): TestRenderer.ReactTestRenderer {
act(() => {
renderer = TestRenderer.create(createElement(NotificationSettingsScreen, { scope }));
});
if (!renderer) {
throw new Error('renderer was not created');
}
return renderer;
}

describe('personal scope', () => {
it('titles each switch after its notification and shows no section heading', () => {
const copy = mount(PERSONAL_SCOPE)
.root.findAllByType('Text')
.map(node => node.props.children);

expect(copy).toContain(i18n.t('securityAgent.notifications.newFindingPersonalTitle'));
expect(copy).toContain(i18n.t('securityAgent.notifications.slaPersonalTitle'));
expect(copy).not.toContain(i18n.t('securityAgent.notifications.newFindingSection'));
expect(copy).not.toContain(i18n.t('securityAgent.notifications.slaSection'));
});

it('spaces the two switch groups like the space above the first one', () => {
const screen = mount(PERSONAL_SCOPE);

expect(screen.root.findByType('TabScreenScrollView').props.contentContainerClassName).toBe(
'px-6 pt-4 gap-4'
);
});
});

describe('organization scope', () => {
it('heads each switch with its notification section, because both read the same recipient', () => {
const names = mount(ORGANIZATION_SCOPE)
.root.findAllByType('Switch')
.map(node => node.props.accessibilityLabel);

expect(names).toHaveLength(2);
expect(names[0]).toContain(i18n.t('securityAgent.notifications.newFindingSection'));
Comment thread
iscekic marked this conversation as resolved.
Resolved
expect(names[1]).toContain(i18n.t('securityAgent.notifications.slaSection'));
expect(names[1]).not.toContain(i18n.t('securityAgent.notifications.newFindingSection'));
});

it('keeps the standard group gap, next to its headings', () => {
const screen = mount(ORGANIZATION_SCOPE);

expect(screen.root.findByType('TabScreenScrollView').props.contentContainerClassName).toBe(
'px-6 pt-4 gap-6'
);
});
});
Loading