mattermost-mobile-upstream/app/queries/servers/properties.test.ts
Pablo Vélez 9183991a0e
MM-70813 - support multi value attribute chips (#10192)
* MM-70813 - support multi value attribute chips

* add missing tests

* adjust e2e test

* update overflowSheet testID to match BottomSheetContent rendering

* resolve attribute placeholders in the native channel banner fallback

* channel info listing, overflow sheet, chip recolour, banner resubscriptions

* show every header attribute and value in the +N overflow sheet

* single banner pipeline, channel info memo and dedupe, utils placement, sheet row styles, e2e flag writes

* honor channel banner toggle and drop classification color once its token is removed from the banner

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
2026-09-30 12:08:22 +02:00

220 lines
9.3 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {waitFor} from '@testing-library/react-native';
import {firstValueFrom} from 'rxjs';
import {setAccessControlGroupId} from '@actions/local/channel_attributes';
import {CLASSIFICATIONS_SYSTEM_VALUE_TARGET_ID} from '@constants/classification';
import DatabaseManager from '@database/manager';
import {
observeChannelAttributeBanner,
observeChannelAttributeFields,
observeClassificationBannerState,
observeResolvedChannelAttributes,
} from './properties';
import type ServerDataOperator from '@database/operator/server_data_operator';
import type {Database} from '@nozbe/watermelondb';
const serverUrl = 'properties.query.test.com';
const groupId = 'access_control';
let database: Database;
let operator: ServerDataOperator;
const makeField = (overrides?: Partial<PropertyField>): PropertyField => ({
id: 'sys-1',
group_id: groupId,
name: 'classification',
type: 'select',
object_type: 'system',
target_type: 'system',
target_id: '',
delete_at: 0,
create_at: 1000,
update_at: 1000,
attrs: {
actions: ['display_banner_top'],
options: [
{id: 'opt-ts', name: 'TOP SECRET', color: '#FCE83A'},
{id: 'opt-s', name: 'SECRET', color: '#FF0000'},
],
},
...overrides,
});
const makeValue = (overrides?: Partial<PropertyValue<string>>): PropertyValue<string> => ({
id: 'val-1',
target_id: CLASSIFICATIONS_SYSTEM_VALUE_TARGET_ID,
target_type: 'system',
group_id: groupId,
field_id: 'sys-1',
value: 'opt-ts',
create_at: 1000,
update_at: 1000,
delete_at: 0,
...overrides,
});
const seedFields = (fields: PropertyField[]) => operator.handlePropertyFields({fields, prepareRecordsOnly: false});
const seedValues = (values: Array<PropertyValue<string>>) => operator.handlePropertyValues({values, prepareRecordsOnly: false});
beforeEach(async () => {
await DatabaseManager.init([serverUrl]);
const db = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
database = db.database;
operator = db.operator;
// Normally published by the field fetch. The channel-field observables scope
// themselves to the group and emit nothing without it.
await setAccessControlGroupId(serverUrl, groupId);
});
afterEach(async () => {
await DatabaseManager.destroyServerDatabase(serverUrl);
});
describe('observeClassificationBannerState', () => {
it('should emit a visible banner state when field and value are present', async () => {
await seedFields([makeField()]);
await seedValues([makeValue()]);
const state = await firstValueFrom(observeClassificationBannerState(database));
expect(state).toEqual({visible: true, levelName: 'TOP SECRET', color: '#FCE83A'});
});
it('should emit a hidden banner state when the field is soft-deleted', async () => {
await seedFields([makeField({delete_at: 5000})]);
await seedValues([makeValue()]);
const state = await firstValueFrom(observeClassificationBannerState(database));
expect(state).toEqual({visible: false, levelName: '', color: ''});
});
it('should emit a hidden banner state when no data exists', async () => {
const state = await firstValueFrom(observeClassificationBannerState(database));
expect(state).toEqual({visible: false, levelName: '', color: ''});
});
});
describe('observeChannelAttributeFields', () => {
it('should emit nothing until the group id is known, since another feature also stores channel fields', async () => {
await seedFields([makeField({id: 'cf-1', object_type: 'channel'})]);
await setAccessControlGroupId(serverUrl, '');
const fields = await firstValueFrom(observeChannelAttributeFields(database));
expect(fields).toHaveLength(0);
});
it('should exclude a field belonging to a different property group', async () => {
await seedFields([
makeField({id: 'cf-1', object_type: 'channel'}),
makeField({id: 'other-1', object_type: 'channel', group_id: 'managed_channel_categories', name: 'category'}),
]);
const fields = await firstValueFrom(observeChannelAttributeFields(database));
expect(fields.map((f) => f.id)).toEqual(['cf-1']);
});
it('should exclude soft-deleted and non-channel fields', async () => {
await seedFields([
makeField({id: 'cf-1', object_type: 'channel'}),
makeField({id: 'cf-2', object_type: 'channel', delete_at: 5000}),
makeField({id: 'sys-1', object_type: 'system'}),
]);
const fields = await firstValueFrom(observeChannelAttributeFields(database));
expect(fields.map((f) => f.id)).toEqual(['cf-1']);
});
});
describe('observeResolvedChannelAttributes', () => {
const channelId = 'channel-123';
it('should pair each channel field with this channel value, in display order', async () => {
await seedFields([
makeField({id: 'cf-2', name: 'program', object_type: 'channel', attrs: {options: [{id: 'aurora', name: 'AURORA'}]}}),
makeField({id: 'cf-1', name: 'classification', object_type: 'channel', attrs: {options: [{id: 'level-secret', name: 'Secret', color: '#FF0000'}]}}),
]);
await seedValues([
makeValue({id: 'cv-1', target_id: channelId, target_type: 'channel', field_id: 'cf-1', value: 'level-secret'}),
makeValue({id: 'cv-2', target_id: channelId, target_type: 'channel', field_id: 'cf-2', value: 'aurora'}),
]);
const resolved = await firstValueFrom(observeResolvedChannelAttributes(database, channelId));
expect(resolved.map((r) => [r.field.name, r.displayValue])).toEqual([
['classification', 'Secret'],
['program', 'AURORA'],
]);
});
it('should re-emit when an option of a multi-valued attribute changes color', async () => {
const options = [{id: 'noforn', name: 'NOFORN', color: '#FF0000'}, {id: 'orcon', name: 'ORCON', color: '#00FF00'}];
await seedFields([makeField({id: 'cf-1', name: 'dissemination', type: 'multiselect', object_type: 'channel', attrs: {options}})]);
await operator.handlePropertyValues({
values: [{...makeValue({id: 'cv-1', target_id: channelId, target_type: 'channel', field_id: 'cf-1'}), value: ['noforn', 'orcon']}],
prepareRecordsOnly: false,
});
const emissions: string[][] = [];
const subscription = observeResolvedChannelAttributes(database, channelId).subscribe((resolved) => {
emissions.push(resolved[0].displayValues.map((entry) => entry.color ?? ''));
});
await seedFields([makeField({
id: 'cf-1',
name: 'dissemination',
type: 'multiselect',
object_type: 'channel',
update_at: 2000,
attrs: {options: [options[0], {...options[1], color: '#0000FF'}]},
})]);
await waitFor(() => expect(emissions).toHaveLength(2));
subscription.unsubscribe();
expect(emissions[1]).toEqual(['#FF0000', '#0000FF']);
});
it('should not leak another channel value into this channel', async () => {
await seedFields([makeField({id: 'cf-1', name: 'classification', object_type: 'channel', attrs: {options: [{id: 'level-secret', name: 'Secret'}]}})]);
await seedValues([makeValue({id: 'cv-1', target_id: 'another-channel', target_type: 'channel', field_id: 'cf-1', value: 'level-secret'})]);
const resolved = await firstValueFrom(observeResolvedChannelAttributes(database, channelId));
expect(resolved).toHaveLength(1);
expect(resolved[0].displayValue).toBe('');
});
});
describe('observeChannelAttributeBanner', () => {
const channelId = 'channel-123';
it('should emit a channel banner from the channel value and field options', async () => {
await seedFields([makeField({id: 'cf-1', name: 'classification', object_type: 'channel', attrs: {options: [{id: 'level-secret', name: 'Secret', color: '#FF0000'}]}})]);
await seedValues([makeValue({id: 'cv-1', target_id: channelId, target_type: 'channel', field_id: 'cf-1', value: 'level-secret'})]);
const state = await firstValueFrom(observeChannelAttributeBanner(database, channelId));
expect(state.hasBanner).toBe(true);
expect(state.banner).toEqual({
enabled: true,
text: '**Secret**',
background_color: '#FF0000',
});
});
it('should emit no banner when there is no channel value', async () => {
await seedFields([makeField({id: 'cf-1', name: 'classification', object_type: 'channel'})]);
const state = await firstValueFrom(observeChannelAttributeBanner(database, channelId));
expect(state.hasBanner).toBe(false);
});
it('should emit no banner when the channel value references a missing option', async () => {
await seedFields([makeField({id: 'cf-1', name: 'classification', object_type: 'channel', attrs: {options: [{id: 'level-secret', name: 'Secret', color: '#FF0000'}]}})]);
await seedValues([makeValue({id: 'cv-1', target_id: channelId, target_type: 'channel', field_id: 'cf-1', value: 'missing-level'})]);
const state = await firstValueFrom(observeChannelAttributeBanner(database, channelId));
expect(state.hasBanner).toBe(false);
});
});