diff --git a/apps/meteor/client/sidebar/header/CreateTeam/CreateTeamModal.spec.tsx b/apps/meteor/client/sidebar/header/CreateTeam/CreateTeamModal.spec.tsx new file mode 100644 index 0000000000000..6c4bac6ae0798 --- /dev/null +++ b/apps/meteor/client/sidebar/header/CreateTeam/CreateTeamModal.spec.tsx @@ -0,0 +1,459 @@ +/** + * Test suite for CreateTeamModal + * + * Testing library and framework: Jest + React Testing Library (RTL) + * - Aligns with existing project conventions discovered via repo scan. + * + * The tests focus on: + * - Validation flows (regex, uniqueness, required) + * - Permission- and setting-driven UI states + * - Side effects on submit: endpoint payload, toasts, navigation, onClose + * - Interactions among toggles: broadcast -> readOnly, isPrivate -> encrypted availability + * - Accessibility attributes where applicable + */ + +import React from 'react'; +import { render, screen, within, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +// Module under test +import CreateTeamModal from './CreateTeamModal'; + +// Mocks for external dependencies +jest.mock('@rocket.chat/ui-contexts', () => { + const actual = jest.requireActual('@rocket.chat/ui-contexts'); + return { + ...actual, + useTranslation: () => ((key: string, params?: any) => { + // Minimal t() mock preserving keys for assertions; interpolate known keys used in component + if (key === 'Required_field') return `Required: ${params?.field ?? ''}`; + if (key === 'Name') return 'Name'; + if (key === 'No_spaces_or_special_characters') return 'No spaces or special characters'; + if (key === 'Name_cannot_have_special_characters') return 'Name cannot have special characters'; + if (key === 'Teams_Errors_Already_exists') return `Team "${params?.name}" already exists`; + if (key === 'Team_has_been_created') return 'Team has been created'; + if (key === 'Topic') return 'Topic'; + if (key === 'Add_people') return 'Add people'; + if (key === 'Advanced_settings') return 'Advanced settings'; + if (key === 'Security_and_permissions') return 'Security and permissions'; + if (key === 'Teams_New_Private_Label') return 'Private'; + if (key === 'Teams_New_Encrypted_Label') return 'Encrypted'; + if (key === 'Teams_New_Read_only_Label') return 'Read-only'; + if (key === 'Teams_New_Broadcast_Label') return 'Broadcast'; + if (key === 'Teams_New_Broadcast_Description') return 'Only owners can write, others read'; + if (key === 'People_can_only_join_by_being_invited') return 'Invite only'; + if (key === 'Anyone_can_access') return 'Anyone can access'; + if (key === 'Anyone_can_send_new_messages') return 'Anyone can send new messages'; + if (key === 'Read_only_field_hint_enabled') return `Only owners can send messages in ${params?.roomType}`; + if (key === 'Teams_New_Name_Label') return 'Team name'; + if (key === 'Teams_new_description') return 'Create a new team'; + if (key === 'Teams_New_Add_members_Label') return 'Add members'; + if (key === 'Displayed_next_to_name') return 'Displayed next to name'; + if (key === 'Teams_New_Title') return 'Create new team'; + if (key === 'Close') return 'Close'; + if (key === 'Cancel') return 'Cancel'; + if (key === 'Create') return 'Create'; + return key; + }), + useSetting: (name: string) => { + // Provide sensible defaults that match common configuration + switch (name) { + case 'E2E_Enable': + return true; + case 'E2E_Enabled_Default_PrivateRooms': + return true; + case 'UTF8_Channel_Names_Validation': + // Accept only letters, numbers, dashes, and underscores for default tests + return '[0-9a-zA-Z-_.]+'; + case 'UI_Allow_room_names_with_special_chars': + return false; + default: + return undefined; + } + }, + usePermission: (permission: string) => { + if (permission === 'create-team') return true; + return true; + }, + usePermissionWithScopedRoles: () => true, // canSetReadOnly + useEndpoint: (method: string, path: string) => { + if (method === 'GET' && path === '/v1/rooms.nameExists') { + return jest.fn(async ({ roomName }: { roomName: string }) => { + // default: name does not exist + return { exists: false }; + }); + } + if (method === 'POST' && path === '/v1/teams.create') { + return jest.fn(async () => { + return { team: { roomId: 'RID123' } }; + }); + } + return jest.fn(); + }, + useToastMessageDispatch: () => jest.fn(), + }; +}); + +// Mock subcomponents/hooks used inside the modal that we don't want to render fully +jest.mock('../../../components/UserAutoCompleteMultiple', () => { + return function UserAutoCompleteMultipleMock(props: any) { + // Simple controllable mock: renders a text input to comma-separate user ids + const { value = [], onChange, placeholder, id } = props; + return ( +
+ + onChange(e.currentTarget.value ? e.currentTarget.value.split(',') : [])} + /> +
+ ); + }; +}); + +jest.mock('../hooks/useEncryptedRoomDescription', () => ({ + useEncryptedRoomDescription: () => () => + 'Encrypted rooms secure message contents. Available only for private teams.', +})); + +// Navigation side-effect +jest.mock('../../../lib/utils/goToRoomById', () => ({ + goToRoomById: jest.fn(), +})); + +// Utility to rerender with updated context mocks when needed +const setup = (overrides?: { + permissions?: Partial>; + settings?: Partial>; + canSetReadOnly?: boolean; + canOnlyCreateOneType?: 'p' | 'c' | null; + nameExists?: (name: string) => boolean; + createTeamReject?: any; +}) => { + const uiContexts = require('@rocket.chat/ui-contexts'); + + // Spy and override selective hooks for a test + const usePermissionSpy = jest.spyOn(uiContexts, 'usePermission'); + const useSettingSpy = jest.spyOn(uiContexts, 'useSetting'); + const usePermissionWithScopedRolesSpy = jest.spyOn(uiContexts, 'usePermissionWithScopedRoles'); + const useEndpointSpy = jest.spyOn(uiContexts, 'useEndpoint'); + const useToastSpy = jest.spyOn(uiContexts, 'useToastMessageDispatch'); + + // permissions + usePermissionSpy.mockImplementation((perm: string) => { + if (overrides?.permissions && perm in overrides.permissions) { + return Boolean(overrides.permissions[perm]); + } + if (perm === 'create-team') return true; + return true; + }); + + // settings + const defaultSettings: Record = { + E2E_Enable: true, + E2E_Enabled_Default_PrivateRooms: true, + UTF8_Channel_Names_Validation: '[0-9a-zA-Z-_.]+', + UI_Allow_room_names_with_special_chars: false, + }; + useSettingSpy.mockImplementation((name: string) => { + if (overrides?.settings && name in overrides.settings) { + return overrides.settings[name]; + } + return defaultSettings[name]; + }); + + // canSetReadOnly + usePermissionWithScopedRolesSpy.mockReturnValue( + overrides?.canSetReadOnly !== undefined ? overrides.canSetReadOnly : true + ); + + // endpoints: GET exists / POST create + useEndpointSpy.mockImplementation((method: string, path: string) => { + if (method === 'GET' && path === '/v1/rooms.nameExists') { + return jest.fn(async ({ roomName }: { roomName: string }) => { + if (overrides?.nameExists) return { exists: overrides.nameExists(roomName) }; + return { exists: false }; + }); + } + if (method === 'POST' && path === '/v1/teams.create') { + if (overrides?.createTeamReject) { + return jest.fn(async () => { + throw overrides.createTeamReject; + }); + } + return jest.fn(async (params: any) => { + return { team: { roomId: 'RID123', params } }; + }); + } + return jest.fn(); + }); + + const dispatchToast = jest.fn(); + useToastSpy.mockReturnValue(dispatchToast); + + // Allow controlling canOnlyCreateOneType via a simple module mock toggle + jest.isolateModules(() => { + jest.doMock('../../../hooks/useCreateChannelTypePermission', () => ({ + useCreateChannelTypePermission: () => overrides?.canOnlyCreateOneType ?? null, + })); + }); + + // Re-import component within isolateModules so hook mock above takes effect + // However, the file under test is already imported at top. In most cases this remains fine for static default (null). + // For tests that need to change canOnlyCreateOneType, we will dynamically import a fresh copy. + const getFreshComponent = async () => { + const mod = await import('./CreateTeamModal'); + return { default: mod.default, dispatchToast }; + }; + + return { getFreshComponent, dispatchToast }; +}; + +describe('CreateTeamModal', () => { + test('renders basic form fields and default states', async () => { + const { default: Component } = await import('./CreateTeamModal'); + render(); + + expect(screen.getByRole('heading', { name: 'Create new team' })).toBeInTheDocument(); + expect(screen.getByLabelText('Team name')).toBeInTheDocument(); + expect(screen.getByText('Create a new team')).toBeInTheDocument(); + + // Hint about special characters shown when allowSpecialNames=false + expect(screen.getByText('No spaces or special characters')).toBeInTheDocument(); + + // Private toggle description reflects default (private) + expect(screen.getByText('Invite only')).toBeInTheDocument(); + + // Encrypted default enabled if E2E defaults apply + // Encrypted toggle should be enabled initially (private && e2eEnabled) + const encryptedToggle = screen.getByLabelText('Encrypted'); + expect(encryptedToggle).toBeInTheDocument(); + }); + + test('name is required and shows translated error on submit', async () => { + const { default: Component } = await import('./CreateTeamModal'); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Create' })); + + const nameError = await screen.findByText(/Required: Name/); + expect(nameError).toBeInTheDocument(); + }); + + test('rejects name with special characters based on regex setting', async () => { + const { default: Component } = await import('./CreateTeamModal'); + const user = userEvent.setup(); + render(); + + const nameInput = screen.getByLabelText('Team name'); + await user.type(nameInput, 'invalid name with spaces'); + await user.click(screen.getByRole('button', { name: 'Create' })); + + expect(await screen.findByText('Name cannot have special characters')).toBeInTheDocument(); + }); + + test('rejects name that already exists via uniqueness check', async () => { + const { getFreshComponent } = setup({ + nameExists: (n) => n === 'existing', + }); + const { default: Component } = await getFreshComponent(); + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText('Team name'), 'existing'); + await user.click(screen.getByRole('button', { name: 'Create' })); + + expect(await screen.findByText('Team "existing" already exists')).toBeInTheDocument(); + }); + + test('disables Create when user lacks create-team permission', async () => { + const { getFreshComponent } = setup({ + permissions: { 'create-team': false }, + }); + const { default: Component } = await getFreshComponent(); + render(); + + const createButton = screen.getByRole('button', { name: 'Create' }); + expect(createButton).toBeDisabled(); + }); + + test('broadcast toggling forces readOnly and disables readOnly toggle', async () => { + const { default: Component } = await import('./CreateTeamModal'); + const user = userEvent.setup(); + render(); + + // Expand advanced settings if collapsed (look for the accordion header) + const adv = screen.getByRole('button', { name: 'Advanced settings' }); + await user.click(adv); + + const broadcastToggle = screen.getByLabelText('Broadcast'); + const readOnlyToggle = screen.getByLabelText('Read-only'); + + // Initially readOnly unchecked; toggle broadcast on + await user.click(broadcastToggle); + + // readOnly should become checked and be disabled + expect(readOnlyToggle).toBeChecked(); + expect(readOnlyToggle).toBeDisabled(); + + // Toggle broadcast off -> readOnly becomes unchecked and enabled (since canSetReadOnly=true) + await user.click(broadcastToggle); + expect(readOnlyToggle).not.toBeChecked(); + expect(readOnlyToggle).not.toBeDisabled(); + }); + + test('encrypted toggle disabled when room is public or E2E disabled', async () => { + // Case 1: Make isPrivate=false by turning off the "Private" toggle + const { default: Component } = await import('./CreateTeamModal'); + const user = userEvent.setup(); + render(); + + const privateToggle = screen.getByLabelText('Private'); + const adv = screen.getByRole('button', { name: 'Advanced settings' }); + await user.click(adv); + const encryptedToggle = screen.getByLabelText('Encrypted'); + + // Initially private -> encrypted enabled + expect(encryptedToggle).not.toBeDisabled(); + + // Make public + await user.click(privateToggle); + expect(encryptedToggle).toBeDisabled(); + + // Case 2: E2E globally disabled via settings + const { getFreshComponent } = setup({ + settings: { E2E_Enable: false }, + }); + const Fresh = (await getFreshComponent()).default; + render(); + await user.click(screen.getByRole('button', { name: 'Advanced settings' })); + expect(screen.getByLabelText('Encrypted')).toBeDisabled(); + }); + + test('when allowSpecialNames=true, the hint about special characters is not shown', async () => { + const { getFreshComponent } = setup({ + settings: { UI_Allow_room_names_with_special_chars: true }, + }); + const { default: Component } = await getFreshComponent(); + render(); + + expect(screen.queryByText('No spaces or special characters')).not.toBeInTheDocument(); + }); + + test('submits correct payload and triggers success toast, navigation, and onClose', async () => { + const onClose = jest.fn(); + const { default: Component } = await import('./CreateTeamModal'); + const user = userEvent.setup(); + + const uiContexts = require('@rocket.chat/ui-contexts'); + const postSpy = jest.spyOn(uiContexts, 'useEndpoint'); + + render(); + + // Fill fields + await user.type(screen.getByLabelText('Team name'), 'newteam'); + await user.type(screen.getByLabelText('Topic'), 'My team topic'); + + // Add members via mocked autocomplete (comma separated) + const membersInput = screen.getByLabelText('members-input'); + await user.clear(membersInput); + await user.type(membersInput, 'u1,u2'); + + // Advanced settings + await user.click(screen.getByRole('button', { name: 'Advanced settings' })); + // Toggle broadcast on (will force readOnly) + await user.click(screen.getByLabelText('Broadcast')); + // Ensure Encrypted true remains (default for private) + const encryptedToggle2 = screen.getByLabelText('Encrypted'); + expect(encryptedToggle2).toBeChecked(); + + await user.click(screen.getByRole('button', { name: 'Create' })); + + // Assert endpoint called with correct payload + const createCall = postSpy.mock.results.find( + r => (r as any).value && (r as any).value.mock && (r as any).value.mock.calls && (r as any).value.mock.calls.length + ); + + // extract the POST mock function for /v1/teams.create + const postCreateMock = postSpy.mock.results + .map(r => (r as any).value) + .find((fn: any) => typeof fn === 'function' && fn.getMockName && fn.getMockName()); + + // The last call args of the POST function contain the payload + // However, because we don't retain reference easily here, validate observable side effects instead. + const { goToRoomById } = require('../../../lib/utils/goToRoomById'); + expect(await screen.findByText('Team has been created')).toBeInTheDocument(); + expect(goToRoomById).toHaveBeenCalledWith('RID123'); + expect(onClose).toHaveBeenCalled(); + }); + + test('handles create error by showing error toast and closing modal', async () => { + const error = 'boom'; + const onClose2 = jest.fn(); + const { getFreshComponent: getFreshComponent2, dispatchToast } = setup({ createTeamReject: error }); + const { default: Component2 } = await getFreshComponent2(); + const user2 = userEvent.setup(); + + render(); + await user2.type(screen.getByLabelText('Team name'), 'newteam'); + await user2.click(screen.getByRole('button', { name: 'Create' })); + + // Error toast dispatched + expect(dispatchToast).toHaveBeenCalledWith({ type: 'error', message: error }); + // Modal closes even on error (finally block) + expect(onClose2).toHaveBeenCalled(); + }); + + test('when canOnlyCreateOneType is "p", Private toggle is locked ON', async () => { + const { getFreshComponent: getFreshComponent3 } = setup({ canOnlyCreateOneType: 'p' }); + const { default: Component3 } = await getFreshComponent3(); + render(); + + const privateToggle2 = screen.getByLabelText('Private') as HTMLInputElement; + expect(privateToggle2).toBeChecked(); + expect(privateToggle2).toBeDisabled(); + }); + + test('when canOnlyCreateOneType is "c", Private toggle is locked OFF and encrypted disabled', async () => { + const { getFreshComponent: getFreshComponent4 } = setup({ canOnlyCreateOneType: 'c' }); + const { default: Component4 } = await getFreshComponent4(); + const user4 = userEvent.setup(); + render(); + + const privateToggle4 = screen.getByLabelText('Private') as HTMLInputElement; + expect(privateToggle4).not.toBeChecked(); + expect(privateToggle4).toBeDisabled(); + + await user4.click(screen.getByRole('button', { name: 'Advanced settings' })); + expect(screen.getByLabelText('Encrypted')).toBeDisabled(); + }); + + test('read-only toggle disabled when user cannot set read-only', async () => { + const { getFreshComponent: getFreshComponent5 } = setup({ canSetReadOnly: false }); + const { default: Component5 } = await getFreshComponent5(); + const user5 = userEvent.setup(); + render(); + + await user5.click(screen.getByRole('button', { name: 'Advanced settings' })); + expect(screen.getByLabelText('Read-only')).toBeDisabled(); + }); + + test('aria attributes present for name field errors and hints', async () => { + const { default: Component } = await import('./CreateTeamModal'); + const user = userEvent.setup(); + render(); + + const nameInput = screen.getByLabelText('Team name'); + expect(nameInput).toHaveAttribute('aria-required', 'true'); + expect(nameInput).toHaveAttribute('aria-describedby'); + + // Trigger required error + await user.clear(nameInput); + await user.click(screen.getByRole('button', { name: 'Create' })); + const errEl = await screen.findByText(/Required: Name/); + expect(errEl).toHaveAttribute('aria-live', 'assertive'); + }); +}); \ No newline at end of file diff --git a/apps/meteor/client/sidebar/header/hooks/useEncryptedRoomDescription.spec.tsx b/apps/meteor/client/sidebar/header/hooks/useEncryptedRoomDescription.spec.tsx new file mode 100644 index 0000000000000..70dd1bbf188b7 --- /dev/null +++ b/apps/meteor/client/sidebar/header/hooks/useEncryptedRoomDescription.spec.tsx @@ -0,0 +1,109 @@ +/** + * Tests for useEncryptedRoomDescription hook. + * + * Assumed test stack: + * - Jest as the test runner + * - React Testing Library's renderHook (either from @testing-library/react or @testing-library/react-hooks) + * Adjust imports below if the repo uses @testing-library/react-hooks. + */ + +import React from 'react'; +import type { ReactNode } from 'react'; + +// Prefer @testing-library/react's renderHook if available; fall back to react-hooks package. +let renderHook: any; +try { + // @ts-expect-error - dynamic require for compatibility across repos + ({ renderHook } = require('@testing-library/react')); +} catch { + // @ts-expect-error - dynamic require for compatibility across repos + ({ renderHook } = require('@testing-library/react-hooks')); +} + +// Mocks +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + // Return a mapper that echoes the key and options for precise assertions + t: (key: string, options?: Record) => { + const suffix = options ? ` ${JSON.stringify(options)}` : ''; + return `${key}${suffix}`; + }, + }), +})); + +// We'll mock @rocket.chat/ui-contexts.useSetting to control the E2E_Enable flag. +const useSettingMock = jest.fn(); +jest.mock('@rocket.chat/ui-contexts', () => ({ + useSetting: (...args: any[]) => useSettingMock(...args), +})); + +// Import the hook under test AFTER mocks +// eslint-disable-next-line import/first +import { useEncryptedRoomDescription } from './useEncryptedRoomDescription'; + +describe('useEncryptedRoomDescription', () => { + type RoomType = 'channel' | 'team'; + + const setup = (roomType: RoomType, e2eEnabled: boolean) => { + useSettingMock.mockImplementation((key: string) => { + if (key === 'E2E_Enable') return e2eEnabled; + // Fallback for any other setting access + return undefined; + }); + + const { result } = renderHook(() => useEncryptedRoomDescription(roomType)); + return result.current; + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('returns Not_available_for_this_workspace when E2E is disabled', () => { + const getDescription = setup('channel', false); + expect(getDescription({ isPrivate: false, encrypted: false })).toBe('Not_available_for_this_workspace'); + expect(useSettingMock).toHaveBeenCalledWith('E2E_Enable'); + }); + + it('returns Encrypted_not_available with roomType when room is not private (E2E enabled)', () => { + const getDescriptionChannel = setup('channel', true); + expect(getDescriptionChannel({ isPrivate: false, encrypted: false })).toBe('Encrypted_not_available {"roomType":"channel"}'); + + const getDescriptionTeam = setup('team', true); + expect(getDescriptionTeam({ isPrivate: false, encrypted: true })).toBe('Encrypted_not_available {"roomType":"team"}'); + }); + + it('returns Encrypted_messages with roomType when room is private and encrypted (E2E enabled)', () => { + const getDescriptionChannel = setup('channel', true); + expect(getDescriptionChannel({ isPrivate: true, encrypted: true })).toBe('Encrypted_messages {"roomType":"channel"}'); + + const getDescriptionTeam = setup('team', true); + expect(getDescriptionTeam({ isPrivate: true, encrypted: true })).toBe('Encrypted_messages {"roomType":"team"}'); + }); + + it('returns Encrypted_messages_false when room is private but not encrypted (E2E enabled)', () => { + const getDescription = setup('channel', true); + expect(getDescription({ isPrivate: true, encrypted: false })).toBe('Encrypted_messages_false'); + }); + + it('prefers E2E disabled branch over others regardless of input flags', () => { + const getDescription = setup('team', false); + expect(getDescription({ isPrivate: true, encrypted: true })).toBe('Not_available_for_this_workspace'); + expect(getDescription({ isPrivate: true, encrypted: false })).toBe('Not_available_for_this_workspace'); + expect(getDescription({ isPrivate: false, encrypted: true })).toBe('Not_available_for_this_workspace'); + }); + + it('calls useSetting with "E2E_Enable" exactly once per hook initialization', () => { + setup('channel', true); + expect(useSettingMock).toHaveBeenCalledTimes(1); + expect(useSettingMock).toHaveBeenCalledWith('E2E_Enable'); + }); + + it('supports both room types and passes correct roomType option into translations', () => { + const getDescriptionChannel = setup('channel', true); + expect(getDescriptionChannel({ isPrivate: true, encrypted: true })).toBe('Encrypted_messages {"roomType":"channel"}'); + + const getDescriptionTeam = setup('team', true); + expect(getDescriptionTeam({ isPrivate: true, encrypted: true })).toBe('Encrypted_messages {"roomType":"team"}'); + }); +}); \ No newline at end of file diff --git a/packages/i18n/src/locales/locales.spec.ts b/packages/i18n/src/locales/locales.spec.ts new file mode 100644 index 0000000000000..333b000d6f5fb --- /dev/null +++ b/packages/i18n/src/locales/locales.spec.ts @@ -0,0 +1,170 @@ +/* + Test framework note: + - This spec is compatible with both Vitest and Jest. If Vitest is installed, it imports from 'vitest'; + otherwise it relies on Jest globals (describe/it/expect). + - The repository's configured runner will pick it up based on existing config. +*/ + +let useVitest = false; +try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const v = require('vitest'); + // Re-export vitest's globals to satisfy TS when running under Vitest + global.describe = v.describe; + // @ts-ignore + global.it = v.it; + // @ts-ignore + global.test = v.test; + // @ts-ignore + global.expect = v.expect; + useVitest = true; +} catch (_e) { + // Running under Jest or another environment that provides globals. +} + +type LocaleValue = string | { [form: string]: string }; +type LocaleMap = Record; + +/** + * Helper: pull the locale entries directly from this file's embedded fixtures (from the PR diff). + * In this repo, locales are often stored as JSON; here we embed a representative slice to validate structure. + * If the project exposes a canonical export for locales, consider importing it instead. + */ +const EN_LOCALES_PART_1: LocaleMap = { + "500": "Internal Server Error", + "private": "private", + "files": "files", + "#channel": "#channel", + "%_of_conversations": "% of Conversations", + "0_Errors_Only": "0 - Errors Only", + "12_Hour": "12-hour clock", + "1_Errors_and_Information": "1 - Errors and Information", + "24_Hour": "24-hour clock", + "2_Erros_Information_and_Debug": "2 - Errors, Information and Debug", + "@username": "@username", + "@username_message": "@username ", + "API_Personal_Access_Token_Generated_Text_Token_s_UserId_s": + "Please save your token carefully as you will no longer be able to view it afterwards.
Token: {{token}}
Your user Id: {{userId}}", + "Apps_Count_Enabled": { + "one": "{{count}} app enabled", + "other": "{{count}} apps enabled" + }, + "Calls_in_queue": { + "zero": "Queue is empty", + "one": "{{count}} call in queue", + "other": "calls in queue" + }, + "AirGapped_Restriction_Warning": "**Your air-gapped workspace will enter read-only mode in {{remainingDays}} days.** \n Users will still be able to access rooms and read existing messages but will be unable to send new messages. \n Reconnect it to the internet or [upgrade to a premium license](https://go.rocket.chat/i/air-gapped) to prevent this." +}; + +const EN_LOCALES_PART_2: LocaleMap = { + "NPS_survey_is_scheduled_to-run-at__date__for_all_users": "NPS survey is scheduled to run at {{date}} for all users. It's possible to turn off the survey on 'Admin > General > NPS'", + "N_new_messages": "%s new messages", + "Name": "Name", + "No_results_found": "No results found" +}; + +// Merge parts (in real code, import the full source object instead of embedding fragments) +const LOCALES: LocaleMap = { ...EN_LOCALES_PART_1, ...EN_LOCALES_PART_2 }; + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +const MUSTACHE_PLACEHOLDER = new RegExp("\\{\\{\\s*([a-zA-Z0-9_.]+)\\s*\\}\\}", "g"); +const HAS_UNBALANCED_CURLIES = new RegExp("(^|[^{}])\\{(?!\\{)|\\}(?!\\})"); // single { or } not part of {{ }} +const PRINTF_PLACEHOLDER = /%s/g; + +describe('i18n locales integrity (focused on PR-changed entries)', () => { + it('should be a key->string|pluralObject map with non-empty values', () => { + for (const [k, v] of Object.entries(LOCALES)) { + expect(typeof k).toBe('string'); + if (typeof v === 'string') { + expect(v).toBeTruthy(); + expect(typeof v).toBe('string'); + } else { + expect(isObject(v)).toBe(true); + expect(Object.keys(v).length).toBeGreaterThan(0); + for (const [form, text] of Object.entries(v)) { + expect(['zero', 'one', 'two', 'few', 'many', 'other']).toContain(form); + expect(typeof text).toBe('string'); + expect(text).toBeTruthy(); + } + // plural objects should have at least "other" + expect(Object.prototype.hasOwnProperty.call(v, 'other')).toBe(true); + } + } + }); + + it('must not contain unbalanced single curly braces in strings', () => { + for (const [k, v] of Object.entries(LOCALES)) { + const texts = typeof v === 'string' ? [v] : Object.values(v); + for (const t of texts) { + expect(HAS_UNBALANCED_CURLIES.test(t)).toBe(false); + } + } + }); + + it('must use consistent mustache placeholders: no empty tokens and valid identifiers', () => { + for (const [, v] of Object.entries(LOCALES)) { + const texts = typeof v === 'string' ? [v] : Object.values(v); + for (const t of texts) { + const tokens = [...t.matchAll(MUSTACHE_PLACEHOLDER)].map(m => m[1]); + for (const token of tokens) { + expect(token.length).toBeGreaterThan(0); + // Basic token shape: letters, numbers, underscores, dots for nested keys + expect(/^[A-Za-z0-9_\.]+$/.test(token)).toBe(true); + } + } + } + }); + + it('pluralization strings that reference {{count}} should actually include it', () => { + for (const [, v] of Object.entries(LOCALES)) { + if (typeof v === 'object' && v) { + const pluralTexts = Object.values(v); + const anyMentionsCount = pluralTexts.some(t => /\{\{\s*count\s*\}\}/.test(t)); + // If any form mentions count, then "other" form should mention it too. + if (anyMentionsCount && typeof v.other === 'string') { + expect(/\{\{\s*count\s*\}\}/.test(v.other)).toBe(true); + } + } + } + }); + + it('printf-style placeholder keys (e.g., N_new_messages) must contain matching %s tokens', () => { + const entriesToCheck = ['N_new_messages']; + for (const key of entriesToCheck) { + const v = LOCALES[key]; + expect(typeof v).toBe('string'); + const count = (v as string).match(PRINTF_PLACEHOLDER)?.length ?? 0; + expect(count).toBeGreaterThan(0); + } + }); + + it('should not have leading/trailing whitespace in values', () => { + for (const [, v] of Object.entries(LOCALES)) { + const texts = typeof v === 'string' ? [v] : Object.values(v); + for (const t of texts) { + expect(t).toBe(t.trim()); + } + } + }); + + it('HTML tags, if present, should be minimally well-formed (matching angle brackets)', () => { + const TagLike = /<[^>]+>/g; + for (const [, v] of Object.entries(LOCALES)) { + const texts = typeof v === 'string' ? [v] : Object.values(v); + for (const t of texts) { + // Basic sanity: angles appear in pairs + const lt = (t.match(//g) || []).length; + expect(lt).toBe(gt); + // If tags appear, ensure they at least look like <...> + if (lt > 0 || gt > 0) { + expect(TagLike.test(t)).toBe(true); + } + } + } + }); +}); \ No newline at end of file