mattermost-mobile-upstream/app/screens/apps_form/apps_form_component.test.ts
Christopher Speller 412f35fcdd
Fix time-of-day flake in apps_form initValues datetime tests (#10013)
Co-authored-by: Matty Code <mattycode@mattycode-4.com>
Co-authored-by: Christopher Speller <crspeller@users.noreply.github.com>
2026-08-06 11:00:58 +00:00

263 lines
10 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import moment from 'moment-timezone';
import {AppFieldTypes} from '@constants/apps';
import {initValues} from './apps_form_component';
describe('initValues', () => {
describe('non-datetime fields', () => {
it('returns empty object for undefined fields', () => {
expect(initValues(undefined)).toEqual({});
});
it('returns empty object for empty array', () => {
expect(initValues([])).toEqual({});
});
it('skips fields without a name', () => {
const values = initValues([
{type: AppFieldTypes.TEXT, value: 'x'} as AppField,
]);
expect(values).toEqual({});
});
it('uses the provided value for non-boolean, non-datetime fields', () => {
const values = initValues([
{name: 'a', type: AppFieldTypes.TEXT, value: 'hello'} as AppField,
]);
expect(values.a).toBe('hello');
});
it('initializes missing values to empty string', () => {
const values = initValues([
{name: 'a', type: AppFieldTypes.TEXT} as AppField,
]);
expect(values.a).toBe('');
});
it('coerces boolean values correctly', () => {
const values = initValues([
{name: 'yes', type: AppFieldTypes.BOOL, value: true} as AppField,
{name: 'no', type: AppFieldTypes.BOOL, value: false} as AppField,
{name: 'str', type: AppFieldTypes.BOOL, value: 'true'} as AppField,
{name: 'empty', type: AppFieldTypes.BOOL} as AppField,
]);
expect(values.yes).toBe(true);
expect(values.no).toBe(false);
expect(values.str).toBe(true);
expect(values.empty).toBe(false);
});
});
describe('datetime field auto-population', () => {
it('auto-populates a required datetime field with no value', () => {
const before = moment();
const values = initValues([
{
name: 'dt',
type: AppFieldTypes.DATETIME,
is_required: true,
} as AppField,
]);
const after = moment();
expect(typeof values.dt).toBe('string');
const parsed = moment(values.dt as string);
expect(parsed.isValid()).toBe(true);
// Default interval is 60 min. Result is always at minute=0 / second=0.
// When `before` falls exactly on an interval boundary (e.g. 13:00:26),
// the implementation keeps the boundary minute and only truncates
// seconds (matching the webapp), so compare against the start of
// `before`'s minute.
expect(parsed.second()).toBe(0);
expect(parsed.millisecond()).toBe(0);
expect(parsed.minute()).toBe(0);
expect(parsed.isSameOrAfter(before.clone().startOf('minute'))).toBe(true);
// Upper bound: within one full interval after `after`
expect(parsed.diff(after, 'minutes')).toBeLessThanOrEqual(60);
});
it('rounds up to the configured time_interval (15-minute field)', () => {
const values = initValues([
{
name: 'dt',
type: AppFieldTypes.DATETIME,
is_required: true,
time_interval: 15,
} as AppField,
]);
const parsed = moment(values.dt as string);
expect([0, 15, 30, 45]).toContain(parsed.minute());
expect(parsed.second()).toBe(0);
});
it('prefers datetime_config.time_interval over time_interval', () => {
const values = initValues([
{
name: 'dt',
type: AppFieldTypes.DATETIME,
is_required: true,
time_interval: 60,
datetime_config: {time_interval: 15},
} as AppField,
]);
const parsed = moment(values.dt as string);
expect([0, 15, 30, 45]).toContain(parsed.minute());
});
describe('time exactly on an interval boundary', () => {
afterEach(() => {
jest.useRealTimers();
});
it('should keep the boundary minute and truncate seconds (no rounding up)', () => {
// Matches the webapp: when minutes % interval === 0 the current
// time is kept and only seconds/milliseconds are cleared, so
// 13:00:26 yields 13:00:00 rather than 14:00:00.
jest.useFakeTimers({doNotFake: ['nextTick']});
jest.setSystemTime(new Date(2026, 0, 15, 13, 0, 26));
const values = initValues([
{
name: 'dt',
type: AppFieldTypes.DATETIME,
is_required: true,
} as AppField,
]);
const parsed = moment(values.dt as string);
expect(parsed.isSame(moment(new Date(2026, 0, 15, 13, 0, 0, 0)))).toBe(true);
});
});
it('does NOT auto-populate a non-required datetime field', () => {
const values = initValues([
{
name: 'dt',
type: AppFieldTypes.DATETIME,
is_required: false,
} as AppField,
]);
expect(values.dt).toBe('');
});
it('does NOT auto-populate when a value is already provided', () => {
const values = initValues([
{
name: 'dt',
type: AppFieldTypes.DATETIME,
is_required: true,
value: '2026-01-15T10:00:00Z',
} as AppField,
]);
expect(values.dt).toBe('2026-01-15T10:00:00Z');
});
describe('min_date / max_date clamping', () => {
it('clamps the default forward when min_date is in the future', () => {
const future = moment().add(30, 'days').format('YYYY-MM-DD');
const values = initValues([
{
name: 'dt',
type: AppFieldTypes.DATETIME,
is_required: true,
min_date: future,
} as AppField,
]);
const parsed = moment(values.dt as string);
// Result should be on or after the min_date
const minMoment = moment(future, 'YYYY-MM-DD');
expect(parsed.isSameOrAfter(minMoment)).toBe(true);
});
it('clamps the default back when max_date is in the past', () => {
const past = moment().subtract(30, 'days').format('YYYY-MM-DD');
const values = initValues([
{
name: 'dt',
type: AppFieldTypes.DATETIME,
is_required: true,
max_date: past,
} as AppField,
]);
const parsed = moment(values.dt as string);
// Result should be on or before max_date end-of-day
// Since max_date is a date-only string parsed at 00:00, parsed should match that date
expect(parsed.format('YYYY-MM-DD')).toBe(past);
});
it('resolves relative min_date expressions (e.g. "+7d")', () => {
const values = initValues([
{
name: 'dt',
type: AppFieldTypes.DATETIME,
is_required: true,
min_date: '+7d',
} as AppField,
]);
const parsed = moment(values.dt as string);
const expectedMin = moment().add(7, 'days').startOf('day');
expect(parsed.isSameOrAfter(expectedMin)).toBe(true);
});
it('does NOT clamp when default is already within bounds', () => {
const before = moment();
const minPast = moment().subtract(30, 'days').format('YYYY-MM-DD');
const maxFuture = moment().add(30, 'days').format('YYYY-MM-DD');
const values = initValues([
{
name: 'dt',
type: AppFieldTypes.DATETIME,
is_required: true,
min_date: minPast,
max_date: maxFuture,
} as AppField,
]);
const parsed = moment(values.dt as string);
const after = moment();
// startOf('minute') tolerates `before` falling exactly on an
// interval boundary, where only seconds are truncated
expect(parsed.isSameOrAfter(before.clone().startOf('minute'))).toBe(true);
expect(parsed.diff(after, 'minutes')).toBeLessThanOrEqual(60);
});
});
describe('location_timezone', () => {
it('uses the field timezone when provided', () => {
const values = initValues([
{
name: 'dt',
type: AppFieldTypes.DATETIME,
is_required: true,
datetime_config: {location_timezone: 'Asia/Tokyo'},
} as AppField,
]);
const parsed = moment(values.dt as string);
expect(parsed.isValid()).toBe(true);
expect(parsed.second()).toBe(0);
});
});
});
describe('date fields (non-datetime) are NOT auto-populated', () => {
it('leaves required date fields as empty string', () => {
const values = initValues([
{
name: 'd',
type: AppFieldTypes.DATE,
is_required: true,
} as AppField,
]);
expect(values.d).toBe('');
});
});
});