Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions src/pages/home/report/ConciergeThinkingMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ function ConciergeThinkingMessageContent({
reportID={report?.reportID}
chatReportID={report?.chatReportID ?? report?.reportID}
action={action}
accountIDs={[CONST.ACCOUNT_ID.CONCIERGE]}
/>
</OfflineWithFeedback>
</View>
Expand Down
107 changes: 107 additions & 0 deletions tests/unit/ConciergeThinkingMessageAvatarTest.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import {render} from '@testing-library/react-native';
import React from 'react';
import Onyx from 'react-native-onyx';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {PersonalDetailsList} from '@src/types/onyx';
import {createAdminRoom, createAnnounceRoom} from '../utils/collections/reports';
import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';

// Capture props passed to ReportActionAvatars
let mockCapturedAvatarProps: Record<string, unknown> = {};

jest.mock('@components/ReportActionAvatars', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-assignment
const {View} = require('react-native');
return (props: Record<string, unknown>) => {
mockCapturedAvatarProps = props;
return <View testID="MockedReportActionAvatars" />;
};
});

// Mock the AgentZero context to make isProcessing=true so the component renders
jest.mock('@pages/inbox/AgentZeroStatusContext', () => ({
useAgentZeroStatus: () => ({
isProcessing: true,
reasoningHistory: [],
statusLabel: 'Thinking...',
}),
}));

// Mock useShouldSuppressConciergeIndicators to return false (don't suppress)
jest.mock('@hooks/useShouldSuppressConciergeIndicators', () => jest.fn(() => false));

// Suppress reanimated/lazy-asset warnings in test
jest.mock('@hooks/useLazyAsset', () => ({
useMemoizedLazyExpensifyIcons: () => ({UpArrow: 'UpArrow', DownArrow: 'DownArrow'}),
}));

// Avoid loading the full ReportActionItemMessageHeaderSender
jest.mock('@pages/inbox/report/ReportActionItemMessageHeaderSender', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-assignment
const {View} = require('react-native');
return () => <View testID="MockedSender" />;
});

// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
const ConciergeThinkingMessage = require('@pages/home/report/ConciergeThinkingMessage').default;

const conciergeAccountID = CONST.ACCOUNT_ID.CONCIERGE;
const conciergeAvatarURL = 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/concierge_2022.png';
const adminPolicyID = '7777';
const adminRoomReportID = 9001;
const announceRoomReportID = 9003;

const mockAdminRoom = {...createAdminRoom(adminRoomReportID), policyID: adminPolicyID};
const mockAnnounceRoom = {...createAnnounceRoom(announceRoomReportID), policyID: adminPolicyID};

const conciergePersonalDetails: PersonalDetailsList = {
[conciergeAccountID]: {
accountID: conciergeAccountID,
displayName: 'Concierge',
login: 'concierge@expensify.com',
avatar: conciergeAvatarURL,
},
};

beforeAll(() => {
Onyx.init({keys: ONYXKEYS});
});

beforeEach(async () => {
mockCapturedAvatarProps = {};
await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, conciergePersonalDetails);
await waitForBatchedUpdates();
});

afterEach(() => {
Onyx.clear();
});

describe('ConciergeThinkingMessage avatar prop integration', () => {
test('should pass accountIDs=[CONCIERGE] to ReportActionAvatars in admin room', () => {
render(<ConciergeThinkingMessage report={mockAdminRoom} />);

expect(mockCapturedAvatarProps.accountIDs).toEqual([conciergeAccountID]);
});

test('should pass accountIDs=[CONCIERGE] to ReportActionAvatars in announce room', () => {
render(<ConciergeThinkingMessage report={mockAnnounceRoom} />);

expect(mockCapturedAvatarProps.accountIDs).toEqual([conciergeAccountID]);
});

test('should pass exactly CONCIERGE account ID, not an empty array', () => {
render(<ConciergeThinkingMessage report={mockAdminRoom} />);

expect(mockCapturedAvatarProps.accountIDs).toBeDefined();
expect((mockCapturedAvatarProps.accountIDs as number[]).length).toBe(1);
expect((mockCapturedAvatarProps.accountIDs as number[]).at(0)).toBe(conciergeAccountID);
});

test('should not pass policyID to ReportActionAvatars (would force workspace avatar)', () => {
render(<ConciergeThinkingMessage report={mockAdminRoom} />);

expect(mockCapturedAvatarProps.policyID).toBeUndefined();
});
});
85 changes: 84 additions & 1 deletion tests/unit/useReportActionAvatarsTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import OnyxListItemProvider from '@components/OnyxListItemProvider';
import useReportActionAvatars from '@components/ReportActionAvatars/useReportActionAvatars';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {PersonalDetailsList} from '@src/types/onyx';
import createRandomPolicy from '../utils/collections/policies';
import createRandomReportAction from '../utils/collections/reportActions';
import {createInvoiceReport, createInvoiceRoom} from '../utils/collections/reports';
import {createAdminRoom, createAnnounceRoom, createInvoiceReport, createInvoiceRoom, createRegularChat} from '../utils/collections/reports';
import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';

const mockPolicyID = '123456';
Expand Down Expand Up @@ -65,4 +66,86 @@ describe('useReportActionAvatars', () => {
expect(data.details.isWorkspaceActor).toEqual(expected);
});
});

describe('Concierge thinking message avatar (issue #620352)', () => {
const conciergeAccountID = CONST.ACCOUNT_ID.CONCIERGE;
const conciergeAvatarURL = 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/concierge_2022.png';
const adminRoomReportID = 9001;
const dmReportID = 9002;
const announceRoomReportID = 9003;
const adminPolicyID = '7777';

const mockAdminRoom = {...createAdminRoom(adminRoomReportID), policyID: adminPolicyID};
const mockAnnounceRoom = {...createAnnounceRoom(announceRoomReportID), policyID: adminPolicyID};
const mockAdminPolicy = {...createRandomPolicy(Number(adminPolicyID), CONST.POLICY.TYPE.TEAM, 'AcmeCorp'), policyID: adminPolicyID};
const mockConciergeDM = {
...createRegularChat(dmReportID, [conciergeAccountID, 12345]),
};

const conciergePersonalDetails: PersonalDetailsList = {
[conciergeAccountID]: {
accountID: conciergeAccountID,
displayName: 'Concierge',
login: 'concierge@expensify.com',
avatar: conciergeAvatarURL,
},
};

beforeEach(async () => {
await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${mockAdminRoom.reportID}`, mockAdminRoom);
await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${mockAnnounceRoom.reportID}`, mockAnnounceRoom);
await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${mockConciergeDM.reportID}`, mockConciergeDM);
await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${adminPolicyID}`, mockAdminPolicy);
await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, conciergePersonalDetails);
await waitForBatchedUpdates();
});

afterEach(() => {
Onyx.clear();
});

// Test 1: Reproduces the bug — admin room without action/accountIDs shows workspace avatar
test('should show workspace avatar in admin room when no action and no accountIDs are provided (bug repro)', () => {
const {
result: {current: data},
} = renderHook(() => useReportActionAvatars({report: mockAdminRoom, action: undefined}), {wrapper});

// BUG: Without the fix, useNearestReportAvatars kicks in and resolves to the workspace icon.
// This assertion documents the CURRENT BUGGY behavior (workspace avatar).
// After the fix (passing accountIDs=[CONCIERGE]), callers will bypass this path.
expect(data.avatars.at(0)?.type).toBe(CONST.ICON_TYPE_WORKSPACE);
});

// Test 2: Verifies the fix — passing accountIDs forces Concierge avatar resolution
test('should resolve Concierge avatar when accountIDs includes Concierge ID in an admin room', () => {
const {
result: {current: data},
} = renderHook(() => useReportActionAvatars({report: mockAdminRoom, action: undefined, accountIDs: [conciergeAccountID]}), {wrapper});

expect(data.avatars.at(0)?.type).toBe(CONST.ICON_TYPE_AVATAR);
expect(data.avatars.at(0)?.source).toBe(conciergeAvatarURL);
expect(data.avatars.at(0)?.id).toBe(conciergeAccountID);
});

// Test 3: Regression — regular Concierge DM still works without action/accountIDs
test('should resolve Concierge avatar in a regular Concierge DM without action or accountIDs', () => {
const {
result: {current: data},
} = renderHook(() => useReportActionAvatars({report: mockConciergeDM, action: undefined}), {wrapper});

// In a 1:1 DM, getIcons resolves participant avatars, not workspace avatars
expect(data.avatars.at(0)?.type).toBe(CONST.ICON_TYPE_AVATAR);
});

// Test 4 (adversarial): Bug is systemic across ALL policy rooms, not just #admins
test('should show workspace avatar in announce room when no action and no accountIDs are provided (bug breadth)', () => {
const {
result: {current: data},
} = renderHook(() => useReportActionAvatars({report: mockAnnounceRoom, action: undefined}), {wrapper});

// Same bug as admin room: useNearestReportAvatars falls through to getIcons(policyRoom) → workspace icon.
// Proves the fix must cover all policy room types, not just admin rooms.
expect(data.avatars.at(0)?.type).toBe(CONST.ICON_TYPE_WORKSPACE);
});
});
});
Loading