mattermost-mobile-upstream/docs/testing_guide.md
Scott Bishel c6b9170d4f
Add date/datetime field support with timezone features for Apps Forms (#9521)
* Add multiselect, dynamic select, multiform, and field refresh support for Interactive Dialogs

This PR adds comprehensive support for advanced interactive dialog features through the DialogRouter component, which routes between legacy InteractiveDialog and modern AppsForm implementations based on the FeatureFlagInteractiveDialogAppsForm feature flag.

Features Added:
- Multiselect support for select fields (users, channels, static options)
- Dynamic select fields with server-side lookup and search
- Multiform/multistep dialogs with state preservation across steps
- Field refresh functionality to update form fields based on selections

Core Components:
- DialogRouter: Routes between legacy InteractiveDialog and AppsForm based on feature flag
- InteractiveDialogAdapter: Converts between dialog formats and handles submission/lookup
- dialog_conversion utilities: Convert DialogElement to AppField format
- Enhanced autocomplete_selector with multiselect support
- Integration selector improvements for multiselect workflows

Technical Implementation:
- Follows webapp DialogRouter pattern (PR #31821)
- Uses WeakMap caching for expensive form conversions
- Proper error handling and validation for all field types
- Comprehensive unit test coverage for conversion logic

Bug Fixes:
- Fix dynamic select to fetch initial options with empty search input
- Fix multiselect value handling in dialog submission
- Add proper multiselect support in dialog-to-form conversion

Note: Changes only affect AppsForm path. Legacy InteractiveDialog remains unchanged and will only support basic features (no multiselect/dynamic/multiform).

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>

* Add E2E tests and test infrastructure for interactive dialog features

E2E Tests Added (18 tests, all passing in ~10 minutes):
- MM-T4976: Multiselect fields (users, channels, options)
- MM-T4977: Dynamic select fields with search
- MM-T4980: Multistep dialog progression
- MM-T4981: Multistep dialog cancellation
- MM-T4983: Field refresh basic interaction
- MM-T4986: Field refresh changes and cancellation
- Plus 12 existing tests for basic dialogs, text fields, boolean fields

Test Infrastructure:
- Add interactive_dialog_test_helper with setup utilities
- Add webhook server support for dynamic plugin installation
- Enhanced integration_selector screen helpers for multiselect testing
- Add helper functions for user/channel selection

Test Optimizations:
- Replace arbitrary 2s waits with waitFor() for dynamic search results
- Reduce buffer waits from 500ms to 300ms where appropriate
- Add swipe gesture in ensureDialogClosed() to reveal posts hidden behind keyboard
- Tests optimized for speed while maintaining reliability

Bug Fixes in Tests:
- Fix MM-T4976 to use selectUser() helper instead of incorrect test data
- Remove multiform code incorrectly merged into MM-T4977 dynamic select test
- Add proper cleanup between tests to prevent state accumulation

All tests verified passing multiple times with proper synchronization.

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>

* Fix integration selector tap handling on Android and update E2E tests

Bug Fix - Integration Selector:
- Replace KeyboardAwareFlatList with regular FlatList to fix tap handling on Android API 34+
- Add platform-specific keyboard dismiss behavior (onScrollBeginDrag for Android, on-drag for iOS)
- Follows the same pattern as invite/selection and user_list components

Issue: KeyboardAwareFlatList prevents taps on search results after typing in the search field on Android API 34+. Users could not select dynamic options after searching, making dynamic select fields unusable on Android.

E2E Test Updates:
- Add waitFor() with 3s timeout instead of immediate expect for dynamic field buttons
- Add 300ms buffer wait after selecting first dynamic option before checking for second field
- Ensures proper timing for dialog re-render after integration selector closes

Testing:
- All 18 E2E tests passing locally on Android Pixel 4 XL API 34 (~8.4 minutes)
- Verified dynamic select, multiselect, multiform, and field refresh functionality

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>

* Address code review feedback for interactive dialog improvements

- Move API call to client layer: Added lookupInteractiveDialog method to client mixin following ClientMix pattern
- Extract error message function: Created getSubmissionErrorMessage helper to improve readability
- Simplify nullish coalescing: Use ?? operator instead of ternary for notify_on_cancel
- Add error logging: Added logDebug in autocomplete selector and performLookupCall catch blocks
- Improve field.name handling: Added early return check and removed unnecessary fallback
- Remove type cast: Use object spread instead of 'as any' for refresh submission
- Simplify refresh logic: Removed verbose debug logs from dialog conversion
- Revert PROFILE_CHUNK_SIZE: Changed back from 10 to 100 (unintentional change)
- Revert test config: Changed serverOneUrl back to localhost (unintentional change)

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>

* Add date/datetime field support with timezone features

  Core Features:
  - Add DATE and DATETIME field types for Apps Forms
  - User timezone and military time preferences via database observables
  - Relative date support (today, tomorrow, +5d, -2w, +1m)
  - Location timezone override with visual indicator (🌍 Times in GMT/PST)
  - Min/max date constraints with relative date resolution
  - Configurable time intervals (iOS: 1-30 min, fallback 60→30)
  - Required field validation prevents empty submission
  - allowPastDates logic (defaults to false, auto-calculated from min_date)

  Implementation:
  - DateTimeSelector component with timezone-aware display
  - parseDateInTimezone utility for proper date parsing
  - Dialog to AppForm conversion preserves datetime_config
  - Proper UTC conversion regardless of display timezone
  - Fix date off-by-one errors from timezone conversion
  - Fix field label overlap with flexbox layout

  Type Definitions:
  - Add min_date, max_date, time_interval, datetime_config to AppField
  - Define DateTimeConfig (time_interval, location_timezone, is_range, etc)
  - Update DialogElement and InteractiveDialogElementType

  Testing:
  - 52 unit tests passing (date utilities, validation, integration)
  - 7 E2E tests passing (field display, validation, timezone, UTC conversion)
  - Comprehensive coverage for relative dates, timezone parsing, validation

  Architecture:
  - withObservables HOC pattern for reactive user preferences
  - Only modifies apps_form (not deprecated interactive_dialog)
  - Clean separation of utilities, components, and observables
  - Follows existing patterns from custom_status/scheduled_posts

* move performDynamicLookup and use currentConfig

* Fix code review findings for datetime field support

- Hoist useMemo/hook calls out of switch-case (Rules of Hooks fix)
- Wrap AppsFormFieldComponent with React.memo
- Extract dateTimeStyles to makeStyleSheetFromTheme at module scope
- i18n: replace hardcoded "Times in", "Select Date", "Select Time" strings
- Remove spurious handleChange calls from picker toggle buttons
- Add .clone() to prevent moment mutations in resolveRelativeDate and getDateValue
- Fix test: remove private _d field access, assert no-call on toggle
- Fix getStyleSheet return syntax in DateTimeSelector

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Align mobile date/datetime with webapp and migrate E2E tests

Date/DateTime parity with webapp:
- Change default time interval from 30 to 60 minutes
- Add time offset support (+H, +M, +S) to resolveRelativeDate
- Add manual time entry with parseTimeString for allow_manual_time_entry
- Auto-populate required datetime fields with rounded current time
- Remove unused range fields from DateTimeConfig type
- Extract DEFAULT_TIME_INTERVAL_MINUTES to shared constant
- Use effectiveInterval for consistent iOS picker/rounding alignment
- Wrap handlers in useCallback, use typography() and changeOpacity()
- Add logWarning for +M disambiguation and null date parse failures
- Internationalize date-time separator string

E2E test infrastructure:
- Migrate all dialog tests from demo plugin to test plugin
- Replace DemoPlugin with TestPlugin constant
- Remove duplicate MM-T4986 test and demo-plugin activation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix line spacing nit

* remove file, update alphabetically

* Fix code review findings for datetime field support

Bug fixes:
- Fix relative time offsets (+2H, +30M) being overwritten with current
  time in getDateValue — skip time replacement for HMS offsets
- Handle Android picker dismiss (event.type === 'dismissed') to prevent
  silently filling fields when user cancels
- Parse minDate/maxDate with field timezone via parseDateInTimezone
  to avoid off-by-one at timezone day boundaries
- Use field's location_timezone in initValues for correct auto-defaults
- Fix parseDateInTimezone to use timezone for date-only strings

Improvements:
- Add database teardown in DateTimeSelector test (afterAll cleanup)
- Fail fast in apiGetLatestPluginVersion instead of wrong fallback
- Stronger E2E assertions: validation error text in MM-T2530B,
  ISO datetime pattern check in MM-T2530F
- Extract ISO_DATETIME_PATTERN constant for E2E test reuse
- Use typography() in apps_form_field.tsx stylesheets
- Internationalize hardcoded 'at' date-time separator

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix by.text() RegExp usage in MM-T2530G timezone test

Detox's by.text() only accepts strings, not RegExp. Use API to fetch
the submission post and validate ISO datetime pattern in JavaScript,
matching the approach used in MM-T2530F.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Address CodeRabbit R2 findings in E2E tests

- Enforce UTC suffix (Z or ±HH:MM) in ISO_DATETIME_PATTERN
- Make required-field validation assertion mandatory (remove try/catch)
- Fail fast when field refresh element not found (remove silent fallback)
- Remove commented-out pluginInstallAndEnable dead code

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add unit tests for datetime business logic

Cover parseTimeString, initValues datetime auto-population,
getDateValue/isTimeOffset, and datetime_config preservation in
dialog_conversion — areas flagged as uncovered in PR test check.
Export the four helpers from their modules so tests can exercise
them directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Pin demo plugin install via fixture upload + add datetime E2E tests

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Auto-download demo plugin tarball into fixtures

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Configure demo plugin user/channel/lastname for fresh-install path

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add datetime E2E tests and isolate iOS 26 keyboard contamination

Adds MM-T2530A-H covering date/datetime field rendering, validation,
picker interaction, defaults, UTC submit, timezone indicator, and manual
time entry. Manual entry placed last (T2530H) since the TextInput
keyboard cycle leaves animation state on iOS 26 +
react-native-keyboard-controller that breaks subsequent dialog tests.

Skips T4983 with TODO referencing the same iOS 26 keyboard contamination
(field-refresh dialog with text inputs poisons RCTRefreshControl with
progressViewOffset NaN). Refines selectUser/selectChannel helpers to
target by entity ID for when the iOS 26 + Detox UserItem TouchableOpacity
hit-test regression is resolved upstream and skipped tests can be
re-enabled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix lint and update tests for updates to main

* fix bad import

* updates for code review, move/rename files

* Restore UserItem testID on the touchable (from reverted #9790)

  #9790 (Rainforest→Detox migration) was reverted in #9834, which dropped
  this fix. On iOS 26, Detox synthetic taps don't propagate from the inner
  testID-bearing View to the parent TouchableOpacity, so integration-selector
  user rows weren't selectable in E2E. Move the testID onto the TouchableOpacity
  (as CustomListRow already does) to make the row tappable again.

* Update user_list snapshot for UserItem testID move

* updates from main merge, review fixes

* fix e2etests

* fix bad merge

* fix failing test caused by bad merge

---------

Co-authored-by: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
2026-07-16 08:09:47 -06:00

67 lines
7.8 KiB
Markdown

# Unit testing guide
This document describes how to add and structure unit tests in the Mattermost Mobile project. For high-level testing notes and patterns, see the **Testing** section in [CLAUDE.md](../CLAUDE.md). For concrete examples, see [app/products/playbooks](../app/products/playbooks), which is the canonical reference for test layout and patterns.
## Where tests live
- Tests are **co-located** with source files: `*.test.ts` or `*.test.tsx` next to the file under test (e.g. `channel.test.ts` beside `channel.ts`).
- When a component is split into `index.tsx` (the `withDatabase`/`withObservables` wiring) and `{name}.tsx` (the component), test the **bare component**: name the test `{name}.test.tsx`, import `./{name}`, and pass the enhancer-injected props (e.g. `isMilitaryTime`) directly. Don't render-test the `index.tsx` wiring. Example: [date_time_selector.test.tsx](../app/components/date_time_selector/date_time_selector.test.tsx).
- Reference: [app/products/playbooks](../app/products/playbooks) for the full pattern.
## What to test by layer
### Local actions (`app/actions/local/`)
- Use a **real in-memory database**: `DatabaseManager.init([serverUrl])` in `beforeEach` and `DatabaseManager.destroyServerDatabase(serverUrl)` in `afterEach`.
- Use **TestHelper** for fake entities: `TestHelper.fakeChannel()`, `TestHelper.fakePost()`, `TestHelper.fakeChannelMember()`, etc.
- Cover: **not-found database** (invalid `serverUrl`), **not-found entity** (e.g. channel/post missing), **success path**, and **error path** (e.g. mock `database.write` or `operator.batchRecords` to throw).
- Example: [app/products/playbooks/actions/local/channel.test.ts](../app/products/playbooks/actions/local/channel.test.ts), [app/actions/local/post.test.ts](../app/actions/local/post.test.ts).
### Remote actions (`app/actions/remote/`)
- **Mock** `NetworkManager.getClient` in `beforeAll` to return a client with the methods used (e.g. `setChannelAutotranslation`, `setMyChannelAutotranslation`).
- Assert the client was called with the expected arguments and that the action returns `{data}` on success and `{error}` on failure.
- On error, assert `forceLogoutIfNecessary` (and any logging) is called when applicable.
- Example: [app/products/playbooks/actions/remote/playbooks.test.ts](../app/products/playbooks/actions/remote/playbooks.test.ts), [app/actions/remote/channel.test.ts](../app/actions/remote/channel.test.ts).
### Queries (`app/queries/servers/`)
- For **query** functions that return a WatermelonDB `Query`: use a real DB, create the relevant records with the operator, run the query, and assert on the fetched results.
- For **observe** functions that return RxJS observables (canonical pattern from playbooks):
1. Use a **real database**: `DatabaseManager.init([serverUrl])` in `beforeEach`, destroy in `afterEach`.
2. **Set up data** in the DB with the operator (e.g. `handleChannel`, `handleMyChannel`, `handleSystem`) so the observe under test has real records to emit.
3. If the observe function depends on other observables from a **mocked module** (e.g. `observeConfigBooleanValue` from `./system`), mock that dependency to return an Observable (e.g. `jest.mocked(observeConfigBooleanValue).mockReturnValue(of$(true))`) so `combineLatest` and similar operators receive valid observables.
4. Call the observe function with `database` (and any ids), then **subscribe** with a spy: `const subscriptionNext = jest.fn(); result.subscribe({ next: subscriptionNext });`
5. **Assert** the emitted value: `expect(subscriptionNext).toHaveBeenCalledWith(expectedValue)`.
- You can also use `firstValueFrom(observable)` to await the first emission when that is clearer.
- Example: [app/products/playbooks/database/queries/run.test.ts](../app/products/playbooks/database/queries/run.test.ts) (`observePlaybookRunById`, `observePlaybookRunProgress`), [app/products/playbooks/database/queries/version.test.ts](../app/products/playbooks/database/queries/version.test.ts) (`observeIsPlaybooksEnabled`), [app/queries/servers/channel.test.ts](../app/queries/servers/channel.test.ts) (`observeChannelAutotranslation`, `observeMyChannelAutotranslation`, `observeIsChannelAutotranslated`).
### Screens and UI
- Use **`renderWithEverything`** from `@test/intl-test-helper` when the component needs database or server URL (pass `{database}` and optionally `serverUrl`). Use **`renderWithIntlAndTheme`** when it only needs theme/intl (no DB).
- Mock heavy or external deps: navigation, remote actions, `useServerUrl`, etc., with `jest.mock(...)`.
- **Mocking child components** : (1) Mock the module with a factory that returns only `{ __esModule: true, default: jest.fn() }`—do **not** use `require()` or other out-of-scope variables inside the factory, or Jest will throw. (2) Import the child component (it will be the mocked reference after hoisting). (3) Call `jest.mocked(ChildComponent).mockImplementation((props) => React.createElement(View, { testID: '...', ...props }))` (or `React.createElement('ComponentName', ...)`) so the implementation runs in test scope where `React` and `View` are in scope. This keeps a stable `testID` and forwards props. Example: [app/products/playbooks/screens/select_user/select_user.test.tsx](../app/products/playbooks/screens/select_user/select_user.test.tsx), [app/products/playbooks/screens/participant_playbooks/participant_playbooks.test.tsx](../app/products/playbooks/screens/participant_playbooks/participant_playbooks.test.tsx).
- **Asserting on props passed to children:** Query the **screen** for the rendered element (e.g. `getByTestId('...')` or `getAllByTestId('...')`), then assert with `expect(element).toHaveProp('propName', value)`. **Do not** use `jest.mocked(Component).mock.calls` or `mock.calls[0][0]` to inspect props—assert on what is in the tree, as in playbooks screen tests.
- Assert that key elements render (e.g. by `testID`) and that user actions (e.g. toggle, button press) call the expected handlers or actions.
- Use `fireEvent.press()` for button/toggle interactions; wrap async updates in `act()` or `waitFor` when needed.
- Use a **`getBaseProps()`** helper typed as `ComponentProps<typeof Component>` for default props and `beforeEach(() => jest.clearAllMocks())` where appropriate.
- Example: [app/products/playbooks/screens/select_user/select_user.test.tsx](../app/products/playbooks/screens/select_user/select_user.test.tsx), [app/screens/show_translation/show_translation.test.tsx](../app/screens/show_translation/show_translation.test.tsx).
### Client REST (`app/client/rest/`)
- Create the client (e.g. `TestHelper.createClient()`), then mock `client.doFetch`.
- Assert the request URL (and query string if any) and options (method, body, etc.), and that the return value or thrown error matches expectations.
- Example: [app/products/playbooks/client/rest.test.ts](../app/products/playbooks/client/rest.test.ts), [app/client/rest/channels.test.ts](../app/client/rest/channels.test.ts).
## Helpers and setup
- **TestHelper** (`test/test_helper.ts`): `fakeChannel`, `fakePost`, `fakeUser`, `fakeChannelMember`, etc.; `createClient()` for REST client tests.
- **Database**: `DatabaseManager.init([serverUrl])` and `DatabaseManager.destroyServerDatabase(serverUrl)`; get `database` and `operator` from `DatabaseManager.getServerDatabaseAndOperator(serverUrl)`.
- **Rendering**: `renderWithEverything(ui, { database, serverUrl })` when the component needs DB or server context.
- **Events**: `DeviceEventEmitter.addListener(Events.SOME_EVENT, callback)` to assert that an event was emitted; call `listener.remove()` in cleanup.
## Jest configuration
- Setup: `test/setup.ts` is run before tests; avoid adding one-off mocks there.
- Coverage: `coveragePathIgnorePatterns` in `jest.config.js` exclude `/components/` and `/screens/`, but tests for critical screens and components are still encouraged for regression safety.