Files
TREK/client/src/components/Collab/CollabChat.test.tsx
T
jubnl b194e8317d feat(pwa): implement real offline mode with IndexedDB sync
Add genuine offline read/write capability for trips:

- Dexie IndexedDB schema (trips, places, packing, todo, budget,
  reservations, files, mutationQueue, syncMeta, blobCache)
- Repo layer for all domains: offline reads from Dexie, writes
  optimistically to Dexie and enqueue mutations for later replay
- Mutation queue with UUID idempotency keys (X-Idempotency-Key),
  FIFO flush, temp-ID reconciliation on 2xx, fail-and-continue on 4xx
- Trip sync manager: caches all trips with end_date >= today or null,
  auto-evicts 7d after end_date, fetches bundle endpoint in one request
- Map tile prefetcher: bbox from place coords, zooms 10-16, 50MB cap,
  warms SW cache via fetch
- Sync triggers: network online → flush + syncAll; WS reconnect →
  flush only (rate-limiter safe); visibilitychange/30s → flush only
- WS remoteEventHandler writes through to Dexie on every event
- Server idempotency middleware + idempotency_keys table (migration 100,
  24h TTL nightly cleanup)
- GET /api/trips/:id/bundle endpoint for efficient single-request sync
- OfflineBanner component: amber (offline) / blue (syncing) / hidden
- OfflineTab in Settings: cached trip list, re-sync and clear actions
- usePendingMutations hook for per-item pending indicators

Closes #505 #541
2026-04-14 23:04:25 +02:00

708 lines
28 KiB
TypeScript

// FE-COMP-CHAT-001 to FE-COMP-CHAT-012
// jsdom doesn't implement scrollTo — mock it to prevent uncaught exceptions from CollabChat's scrollToBottom
beforeAll(() => {
Element.prototype.scrollTo = vi.fn() as any;
});
// CollabChat uses addListener/removeListener from websocket — extend the global mock
vi.mock('../../api/websocket', () => ({
connect: vi.fn(),
disconnect: vi.fn(),
getSocketId: vi.fn(() => null),
setRefetchCallback: vi.fn(),
setPreReconnectHook: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
}));
import { render, screen, waitFor, act, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import { useSettingsStore } from '../../store/settingsStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip } from '../../../tests/helpers/factories';
import CollabChat from './CollabChat';
import { addListener } from '../../api/websocket';
const currentUser = buildUser({ id: 1, username: 'testuser' });
const defaultProps = {
tripId: 1,
currentUser,
};
beforeEach(() => {
resetAllStores();
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({ messages: [], total: 0 })
),
);
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
});
describe('CollabChat', () => {
it('FE-COMP-CHAT-001: renders without crashing', () => {
render(<CollabChat {...defaultProps} />);
expect(document.body).toBeInTheDocument();
});
it('FE-COMP-CHAT-002: shows empty state when no messages', async () => {
render(<CollabChat {...defaultProps} />);
await screen.findByText('Start the conversation');
});
it('FE-COMP-CHAT-003: shows message input placeholder', async () => {
render(<CollabChat {...defaultProps} />);
// Wait for loading to complete
await screen.findByText('Start the conversation');
expect(screen.getByPlaceholderText('Type a message...')).toBeInTheDocument();
});
it('FE-COMP-CHAT-004: shows send button (ArrowUp icon, no title)', async () => {
render(<CollabChat {...defaultProps} />);
await screen.findByText('Start the conversation');
// Send button has no title attr — verify buttons exist in the toolbar area
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBeGreaterThan(0);
});
it('FE-COMP-CHAT-005: shows existing messages from API', async () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: currentUser.id, username: 'testuser',
avatar_url: null, text: 'Hello world!', created_at: '2025-06-01T10:00:00.000Z',
reactions: {}, reply_to: null, deleted: false, edited: false,
}],
total: 1,
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('Hello world!');
});
it('FE-COMP-CHAT-006: typing in input updates text field', async () => {
const user = userEvent.setup();
render(<CollabChat {...defaultProps} />);
await screen.findByText('Start the conversation');
const input = screen.getByPlaceholderText('Type a message...');
await user.type(input, 'Test message');
expect((input as HTMLTextAreaElement).value).toBe('Test message');
});
it('FE-COMP-CHAT-007: submitting message via Enter calls POST API', async () => {
const user = userEvent.setup();
let postCalled = false;
server.use(
http.post('/api/trips/1/collab/messages', async () => {
postCalled = true;
return HttpResponse.json({
id: 2, trip_id: 1, user_id: 1, username: 'testuser',
avatar_url: null, text: 'New message', created_at: new Date().toISOString(),
reactions: {}, reply_to: null, deleted: false, edited: false,
});
})
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('Start the conversation');
const input = screen.getByPlaceholderText('Type a message...');
// Enter key sends message (Shift+Enter = newline, Enter = send)
await user.type(input, 'New message{Enter}');
await waitFor(() => expect(postCalled).toBe(true));
});
it('FE-COMP-CHAT-008: message input area is present after loading', async () => {
render(<CollabChat {...defaultProps} />);
await screen.findByText('Start the conversation');
expect(screen.getByPlaceholderText('Type a message...')).toBeInTheDocument();
});
it('FE-COMP-CHAT-009: shows hint text in empty state', async () => {
render(<CollabChat {...defaultProps} />);
await screen.findByText(/Share ideas, plans/i);
});
it('FE-COMP-CHAT-010: chat container renders', () => {
render(<CollabChat {...defaultProps} />);
expect(document.body.children.length).toBeGreaterThan(0);
});
it('FE-COMP-CHAT-011: multiple messages all render', async () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [
{ id: 1, trip_id: 1, user_id: 1, username: 'testuser', avatar_url: null, text: 'First message', created_at: '2025-06-01T10:00:00.000Z', reactions: {}, reply_to: null, deleted: false, edited: false },
{ id: 2, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null, text: 'Second message', created_at: '2025-06-01T10:01:00.000Z', reactions: {}, reply_to: null, deleted: false, edited: false },
],
total: 2,
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('First message');
expect(screen.getByText('Second message')).toBeInTheDocument();
});
it('FE-COMP-CHAT-012: shows emoji button in the toolbar', async () => {
render(<CollabChat {...defaultProps} />);
await screen.findByText('Start the conversation');
// Emoji button is a button in the toolbar
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBeGreaterThan(0);
});
it('FE-COMP-CHAT-013: date separator shows "Today" for messages sent today', async () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: 'Hello world!', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
}],
total: 1,
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('Hello world!');
expect(screen.getByText('Today')).toBeInTheDocument();
});
it('FE-COMP-CHAT-014: Shift+Enter inserts a newline instead of sending', async () => {
const user = userEvent.setup();
let postCalled = false;
server.use(
http.post('/api/trips/1/collab/messages', async () => {
postCalled = true;
return HttpResponse.json({});
})
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('Start the conversation');
const input = screen.getByPlaceholderText('Type a message...');
await user.click(input);
await user.type(input, 'Line1');
await user.keyboard('{Shift>}{Enter}{/Shift}');
await user.type(input, 'Line2');
expect((input as HTMLTextAreaElement).value).toContain('\n');
expect(postCalled).toBe(false);
});
it('FE-COMP-CHAT-015: deleted message shows fallback text', async () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: 'some text', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: true, edited: false,
}],
total: 1,
})
)
);
render(<CollabChat {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText(/deleted/i)).toBeInTheDocument();
});
});
it('FE-COMP-CHAT-017: reaction badge renders for a message with reactions', async () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: 'React to me', created_at: new Date().toISOString(),
reactions: [{ emoji: '❤️', count: 1, users: [{ user_id: 2, username: 'alice' }] }],
reply_to: null, deleted: false, edited: false,
}],
total: 1,
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('React to me');
// ReactionBadge renders a button containing a TwemojiImg with alt=emoji
const img = screen.getByAltText('❤️');
expect(img).toBeInTheDocument();
});
it('FE-COMP-CHAT-018: WebSocket collab:message:created event adds message to list', async () => {
vi.clearAllMocks();
render(<CollabChat {...defaultProps} />);
await screen.findByText('Start the conversation');
await waitFor(() => expect(addListener).toHaveBeenCalled());
const handler = (addListener as any).mock.calls[0][0];
await act(async () => {
handler({
type: 'collab:message:created',
tripId: 1,
message: {
id: 99, trip_id: 1, user_id: 2, username: 'alice',
text: 'WS message', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
},
});
});
expect(await screen.findByText('WS message')).toBeInTheDocument();
});
it('FE-COMP-CHAT-019: WebSocket collab:message:deleted event marks message as deleted', async () => {
vi.clearAllMocks();
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: 'To remove', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
}],
total: 1,
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('To remove');
await waitFor(() => expect(addListener).toHaveBeenCalled());
const handler = (addListener as any).mock.calls[0][0];
await act(async () => {
handler({ type: 'collab:message:deleted', tripId: 1, messageId: 1 });
});
await waitFor(() => {
expect(screen.queryByText('To remove')).not.toBeInTheDocument();
});
expect(screen.getByText(/deleted/i)).toBeInTheDocument();
});
it('FE-COMP-CHAT-020: send button is disabled when input is empty', async () => {
render(<CollabChat {...defaultProps} />);
await screen.findByText('Start the conversation');
const buttons = screen.getAllByRole('button');
// The send button is the ArrowUp button — it has disabled attr when text is empty
const sendButton = buttons.find(b => b.hasAttribute('disabled'));
expect(sendButton).toBeTruthy();
expect(sendButton).toBeDisabled();
});
it('FE-COMP-CHAT-021: reply-to banner shows quoted author and text', async () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: 'Reply here', created_at: new Date().toISOString(),
reactions: [], reply_to: null,
reply_text: 'Original message', reply_username: 'alice',
deleted: false, edited: false,
}],
total: 1,
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('Reply here');
expect(screen.getByText(/Original message/i)).toBeInTheDocument();
});
it('FE-COMP-CHAT-022: own messages are displayed with blue bubble', async () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: currentUser.id, username: 'testuser', avatar_url: null,
text: 'My own message', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
}],
total: 1,
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('My own message');
// Own messages don't show a username label above the bubble (only other users get it)
// The component renders {!own && isNewGroup && <span>{msg.username}</span>}
// so 'testuser' should NOT appear as a username label
const usernameLabels = screen.queryAllByText('testuser');
expect(usernameLabels.length).toBe(0);
// And own message bubble uses row-reverse flex direction
const messageEl = screen.getByText('My own message');
let parent = messageEl.parentElement;
let foundRowReverse = false;
while (parent) {
const styleAttr = parent.getAttribute('style');
if (styleAttr && styleAttr.includes('row-reverse')) {
foundRowReverse = true;
break;
}
parent = parent.parentElement;
}
expect(foundRowReverse).toBe(true);
});
it('FE-COMP-CHAT-023: sending a message clears the input field', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/trips/1/collab/messages', async () =>
HttpResponse.json({
message: {
id: 2, trip_id: 1, user_id: 1, username: 'testuser',
avatar_url: null, text: 'Sent message', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
},
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('Start the conversation');
const input = screen.getByPlaceholderText('Type a message...');
await user.type(input, 'Sent message');
expect((input as HTMLTextAreaElement).value).toBe('Sent message');
await user.keyboard('{Enter}');
await waitFor(() => {
expect((input as HTMLTextAreaElement).value).toBe('');
});
});
it('FE-COMP-CHAT-024: load earlier messages button appears when 100+ messages exist', async () => {
const messages = Array.from({ length: 100 }, (_, i) => ({
id: i + 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: `Message ${i + 1}`, created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
}));
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({ messages, total: 100 })
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('Message 1');
const loadMoreBtn = await screen.findByRole('button', { name: /load/i });
expect(loadMoreBtn).toBeInTheDocument();
});
it('FE-COMP-CHAT-025: clicking reply button on a message sets reply-to preview', async () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: 'Reply to me', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
}],
total: 1,
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('Reply to me');
// Hover action buttons are always in DOM but hidden via pointer-events: none
// Use fireEvent to bypass CSS pointer-events restrictions
const replyBtn = screen.getByTitle('Reply');
fireEvent.click(replyBtn);
// Reply preview banner renders <strong>{username}</strong> — unique to the banner
await waitFor(() => {
const aliceEls = screen.queryAllByText('alice');
expect(aliceEls.some(el => el.tagName === 'STRONG')).toBe(true);
});
});
it('FE-COMP-CHAT-026: clicking X in reply preview cancels reply', async () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: 'Cancel reply test', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
}],
total: 1,
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('Cancel reply test');
// Click reply button to show preview (bypassing pointer-events: none)
fireEvent.click(screen.getByTitle('Reply'));
// Wait for reply preview <strong> to appear
await waitFor(() => {
const aliceEls = screen.queryAllByText('alice');
expect(aliceEls.some(el => el.tagName === 'STRONG')).toBe(true);
});
// Find the X button inside the reply preview — the <strong> is inside a <span> inside the preview div
const strongEl = screen.getAllByText('alice').find(el => el.tagName === 'STRONG')!;
const previewDiv = strongEl.closest('div[style]');
const xBtn = previewDiv?.querySelector('button');
expect(xBtn).toBeTruthy();
fireEvent.click(xBtn!);
await waitFor(() => {
// After cancel, no <strong>alice</strong> in reply preview
const remaining = screen.queryAllByText('alice');
expect(remaining.every(el => el.tagName !== 'STRONG')).toBe(true);
});
});
it('FE-COMP-CHAT-027: clicking emoji button opens the emoji picker', async () => {
const user = userEvent.setup();
render(<CollabChat {...defaultProps} />);
await screen.findByText('Start the conversation');
// Smile button is the only non-disabled button when input is empty
const allButtons = screen.getAllByRole('button');
const smileBtn = allButtons.find(b => !b.hasAttribute('disabled'));
expect(smileBtn).toBeTruthy();
await user.click(smileBtn!);
// EmojiPicker renders category tabs
await screen.findByText('Smileys');
expect(screen.getByText('Reactions')).toBeInTheDocument();
});
it('FE-COMP-CHAT-028: selecting emoji from picker appends it to the input', async () => {
const user = userEvent.setup();
render(<CollabChat {...defaultProps} />);
await screen.findByText('Start the conversation');
const allButtons = screen.getAllByRole('button');
const smileBtn = allButtons.find(b => !b.hasAttribute('disabled'));
await user.click(smileBtn!);
// Wait for picker to open
await screen.findByText('Smileys');
// Click the first emoji in the grid (😀 is the first in Smileys)
const emojiImg = screen.getAllByRole('img').find(img => img.getAttribute('alt') === '😀');
expect(emojiImg).toBeTruthy();
await user.click(emojiImg!.closest('button')!);
// Emoji should be appended to textarea
const textarea = screen.getByPlaceholderText('Type a message...');
expect((textarea as HTMLTextAreaElement).value).toContain('😀');
});
it('FE-COMP-CHAT-029: right-clicking a message opens the reaction menu', async () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: 'Right click me', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
}],
total: 1,
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('Right click me');
const messageBubble = screen.getByText('Right click me').closest('div[style]');
fireEvent.contextMenu(messageBubble!);
// ReactionMenu renders quick reactions (❤️ is the first)
await waitFor(() => {
const reactionImgs = screen.getAllByRole('img').filter(img =>
['❤️', '😂', '👍'].includes(img.getAttribute('alt') || '')
);
expect(reactionImgs.length).toBeGreaterThan(0);
});
});
it('FE-COMP-CHAT-030: clicking a reaction in the menu calls reactMessage API', async () => {
let reactCalled = false;
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: 'React to this', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
}],
total: 1,
})
),
http.post('/api/trips/1/collab/messages/1/react', async () => {
reactCalled = true;
return HttpResponse.json({ reactions: [{ emoji: '❤️', count: 1, users: [{ user_id: 1, username: 'testuser' }] }] });
})
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('React to this');
// Open reaction context menu
const messageBubble = screen.getByText('React to this').closest('div[style]');
fireEvent.contextMenu(messageBubble!);
// Wait for menu and click first reaction (❤️)
const heartImg = await screen.findByAltText('❤️');
fireEvent.click(heartImg.closest('button')!);
await waitFor(() => expect(reactCalled).toBe(true));
});
it('FE-COMP-CHAT-031: WebSocket collab:message:reacted event updates reactions', async () => {
vi.clearAllMocks();
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: 'Reacted message', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
}],
total: 1,
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('Reacted message');
await waitFor(() => expect(addListener).toHaveBeenCalled());
const handler = (addListener as any).mock.calls[0][0];
await act(async () => {
handler({
type: 'collab:message:reacted',
tripId: 1,
messageId: 1,
reactions: [{ emoji: '🔥', count: 1, users: [{ user_id: 2, username: 'alice' }] }],
});
});
await screen.findByAltText('🔥');
});
it('FE-COMP-CHAT-032: clicking "Load older messages" loads paginated results', async () => {
const initialMessages = Array.from({ length: 100 }, (_, i) => ({
id: i + 100, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: `New ${i + 100}`, created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
}));
let callCount = 0;
server.use(
http.get('/api/trips/1/collab/messages', () => {
callCount++;
if (callCount === 1) {
return HttpResponse.json({ messages: initialMessages, total: 120 });
}
return HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: 'Older message', created_at: '2020-01-01T10:00:00.000Z',
reactions: [], reply_to: null, deleted: false, edited: false,
}],
total: 120,
});
})
);
const user = userEvent.setup();
render(<CollabChat {...defaultProps} />);
await screen.findByText('New 100');
const loadMoreBtn = screen.getByRole('button', { name: /load/i });
await user.click(loadMoreBtn);
await screen.findByText('Older message');
});
it('FE-COMP-CHAT-033: clicking delete on own message marks it as deleted', async () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: currentUser.id, username: 'testuser', avatar_url: null,
text: 'Delete me', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
}],
total: 1,
})
),
http.delete('/api/trips/1/collab/messages/1', () =>
HttpResponse.json({ success: true })
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('Delete me');
// Delete button is in a hover-actions div with pointer-events: none — use fireEvent
const deleteBtn = screen.getByTitle('Delete');
fireEvent.click(deleteBtn);
// handleDelete uses a 400ms setTimeout before calling the API
await waitFor(
() => expect(screen.getByText(/deleted/i)).toBeInTheDocument(),
{ timeout: 1500 }
);
});
it('FE-COMP-CHAT-034: single-emoji message renders as big emoji', async () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: '👍', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
}],
total: 1,
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('👍');
// Big emoji renders in a div with fontSize: 40px — include emojiEl itself in search
const emojiEl = screen.getByText('👍');
let el: HTMLElement | null = emojiEl as HTMLElement;
let foundBigEmoji = false;
while (el) {
const styleAttr = el.getAttribute('style');
if (styleAttr && styleAttr.includes('font-size: 40px')) {
foundBigEmoji = true;
break;
}
el = el.parentElement;
}
expect(foundBigEmoji).toBe(true);
});
it('FE-COMP-CHAT-035: 24h time format renders timestamp without AM/PM', async () => {
seedStore(useSettingsStore, { settings: { time_format: '24h' } as any });
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: 'Time format test', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
}],
total: 1,
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('Time format test');
// 24h format: timestamp like "HH:MM" — no AM/PM suffix
expect(screen.queryByText(/AM|PM/)).not.toBeInTheDocument();
// There should be a timestamp element matching HH:MM
const timestamp = screen.getByText((text) => /^\d{1,2}:\d{2}$/.test(text));
expect(timestamp).toBeInTheDocument();
});
it('FE-COMP-CHAT-036: message with URL shows link preview when API returns data', async () => {
const uniqueUrl = 'https://preview-test-unique-url-9999.example.com/page';
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: `Check this out ${uniqueUrl}`,
created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
}],
total: 1,
})
),
http.get('/api/trips/1/collab/link-preview', () =>
HttpResponse.json({ title: 'Preview Title', description: 'Preview Desc', image: null, site_name: 'Example' })
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText(/Check this out/);
await waitFor(
() => expect(screen.getByText('Preview Title')).toBeInTheDocument(),
{ timeout: 3000 }
);
});
});