mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +00:00
0dd07befa7
The Plan tab was the last trip tab still in the old design. It now reads like Bookings, Costs, Lists, Files and Collab, without losing a function: - Day plan: each day is a card with a head band (day tile with weather, stay and rental pills, a "+" menu for place, stay, transport, transit and note), rows with "..." menus, white badges and a white route bar. - Places column: add, search and filters in three rows, a show dropdown, category and rating filters, and a selection bar at the foot. - Place inspector, day details and the stay editor on the shared dialog frame; the place form in the editor look with the name in the head band. - Clicking a booking or transport anywhere on the plan opens the booking detail popup first; Edit leads to the editor. The popup is one host shared with the Bookings and Transports tabs. - Files and Collab dialogs, notes, polls, links and chat moved to the same frame; shared DialogShell and dialogParts hold the building blocks. - The map hover card shows the place photo and follows the theme. Every new and reworded string is translated into all locales, and a few older English leftovers (help center, cost split, import hints) with them. Phone screens keep their own look.
866 lines
35 KiB
TypeScript
866 lines
35 KiB
TypeScript
// FE-COMP-POLLS-001 to FE-COMP-POLLS-015
|
|
|
|
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, fireEvent, act } from '../../../tests/helpers/render';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { http, HttpResponse, delay } from 'msw';
|
|
import { server } from '../../../tests/helpers/msw/server';
|
|
import { useAuthStore } from '../../store/authStore';
|
|
import { useTripStore } from '../../store/tripStore';
|
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
|
import { buildUser, buildTrip } from '../../../tests/helpers/factories';
|
|
import CollabPolls from './CollabPolls';
|
|
import { addListener } from '../../api/websocket';
|
|
|
|
const currentUser = buildUser({ id: 1, username: 'testuser' });
|
|
|
|
const buildPoll = (overrides: Record<string, unknown> = {}) => ({
|
|
id: 1,
|
|
question: 'Best destination?',
|
|
options: [
|
|
{ id: 1, text: 'Paris', label: 'Paris', voters: [] },
|
|
{ id: 2, text: 'Rome', label: 'Rome', voters: [] },
|
|
],
|
|
multiple_choice: false,
|
|
is_closed: false,
|
|
deadline: null,
|
|
created_by: 1,
|
|
created_at: new Date().toISOString(),
|
|
...overrides,
|
|
});
|
|
|
|
const defaultProps = { tripId: 1, currentUser };
|
|
|
|
beforeEach(() => {
|
|
resetAllStores();
|
|
vi.clearAllMocks();
|
|
server.use(
|
|
http.get('/api/trips/1/collab/polls', () =>
|
|
HttpResponse.json({ polls: [] }),
|
|
),
|
|
);
|
|
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
|
|
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 1 }) });
|
|
});
|
|
|
|
describe('CollabPolls', () => {
|
|
it('FE-COMP-POLLS-001: renders empty state when no polls exist', async () => {
|
|
render(<CollabPolls {...defaultProps} />);
|
|
expect(await screen.findByText(/no polls yet|collab\.polls\.empty/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-POLLS-002: shows loading spinner initially', async () => {
|
|
server.use(
|
|
http.get('/api/trips/1/collab/polls', async () => {
|
|
await new Promise((r) => setTimeout(r, 200));
|
|
return HttpResponse.json({ polls: [] });
|
|
}),
|
|
);
|
|
render(<CollabPolls {...defaultProps} />);
|
|
// The spinner is a div with animation style
|
|
expect(
|
|
document.querySelector('[style*="animation"]'),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-POLLS-003: renders poll question from API', async () => {
|
|
server.use(
|
|
http.get('/api/trips/1/collab/polls', () =>
|
|
HttpResponse.json({ polls: [buildPoll()] }),
|
|
),
|
|
);
|
|
render(<CollabPolls {...defaultProps} />);
|
|
expect(await screen.findByText('Best destination?')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-POLLS-004: renders poll options', async () => {
|
|
server.use(
|
|
http.get('/api/trips/1/collab/polls', () =>
|
|
HttpResponse.json({ polls: [buildPoll()] }),
|
|
),
|
|
);
|
|
render(<CollabPolls {...defaultProps} />);
|
|
await screen.findByText('Paris');
|
|
expect(screen.getByText('Rome')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-POLLS-005: New Poll button is visible when user can edit', async () => {
|
|
render(<CollabPolls {...defaultProps} />);
|
|
// Wait for loading to finish
|
|
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
|
expect(
|
|
screen.getByRole('button', { name: /new/i }),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-POLLS-006: clicking New Poll button opens the create modal', async () => {
|
|
const user = userEvent.setup();
|
|
render(<CollabPolls {...defaultProps} />);
|
|
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
|
await user.click(screen.getByRole('button', { name: /new/i }));
|
|
// Modal has a question placeholder input
|
|
expect(await screen.findByPlaceholderText(/what should we do/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-POLLS-007: create modal requires question and at least 2 options to enable submit', async () => {
|
|
const user = userEvent.setup();
|
|
render(<CollabPolls {...defaultProps} />);
|
|
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
|
await user.click(screen.getByRole('button', { name: /new/i }));
|
|
|
|
// Find submit button - it's the form submit with the create label
|
|
const submitBtn = screen.getByRole('button', { name: /create|collab\.polls\.create/i });
|
|
expect(submitBtn).toBeDisabled();
|
|
|
|
// Fill in question
|
|
const questionInput = screen.getByPlaceholderText(/what should we do/i);
|
|
await user.type(questionInput, 'Where to go?');
|
|
|
|
// Still disabled — no options filled
|
|
expect(submitBtn).toBeDisabled();
|
|
|
|
// Fill in 2 options
|
|
const optionInputs = screen.getAllByPlaceholderText(/option/i);
|
|
await user.type(optionInputs[0], 'Beach');
|
|
await user.type(optionInputs[1], 'Mountain');
|
|
|
|
expect(submitBtn).toBeEnabled();
|
|
});
|
|
|
|
it('FE-COMP-POLLS-008: creating a poll calls POST API and adds it to the list', async () => {
|
|
const user = userEvent.setup();
|
|
server.use(
|
|
http.post('/api/trips/1/collab/polls', () =>
|
|
HttpResponse.json({ poll: buildPoll({ id: 99, question: 'Where to eat?' }) }),
|
|
),
|
|
);
|
|
render(<CollabPolls {...defaultProps} />);
|
|
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
|
|
|
await user.click(screen.getByRole('button', { name: /new/i }));
|
|
await user.type(screen.getByPlaceholderText(/what should we do/i), 'Where to eat?');
|
|
const optionInputs = screen.getAllByPlaceholderText(/option/i);
|
|
await user.type(optionInputs[0], 'Italian');
|
|
await user.type(optionInputs[1], 'Japanese');
|
|
|
|
await user.click(screen.getByRole('button', { name: /create|collab\.polls\.create/i }));
|
|
expect(await screen.findByText('Where to eat?')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-POLLS-009: voting on an option calls POST vote API', async () => {
|
|
let voteCalled = false;
|
|
server.use(
|
|
http.get('/api/trips/1/collab/polls', () =>
|
|
HttpResponse.json({ polls: [buildPoll()] }),
|
|
),
|
|
http.post('/api/trips/1/collab/polls/1/vote', () => {
|
|
voteCalled = true;
|
|
return HttpResponse.json({
|
|
poll: buildPoll({
|
|
options: [
|
|
{ id: 1, text: 'Paris', label: 'Paris', voters: [{ user_id: 1, username: 'testuser', avatar_url: null }] },
|
|
{ id: 2, text: 'Rome', label: 'Rome', voters: [] },
|
|
],
|
|
}),
|
|
});
|
|
}),
|
|
);
|
|
const user = userEvent.setup();
|
|
render(<CollabPolls {...defaultProps} />);
|
|
await screen.findByText('Paris');
|
|
await user.click(screen.getByText('Paris'));
|
|
await waitFor(() => expect(voteCalled).toBe(true));
|
|
});
|
|
|
|
it('FE-COMP-POLLS-010: closed poll shows "Closed" badge', async () => {
|
|
server.use(
|
|
http.get('/api/trips/1/collab/polls', () =>
|
|
HttpResponse.json({ polls: [buildPoll({ is_closed: true })] }),
|
|
),
|
|
);
|
|
render(<CollabPolls {...defaultProps} />);
|
|
expect(await screen.findByText(/closed/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-POLLS-011: closed poll options are disabled (cannot vote)', async () => {
|
|
server.use(
|
|
http.get('/api/trips/1/collab/polls', () =>
|
|
HttpResponse.json({ polls: [buildPoll({ is_closed: true })] }),
|
|
),
|
|
);
|
|
render(<CollabPolls {...defaultProps} />);
|
|
await screen.findByText('Paris');
|
|
const parisBtn = screen.getByText('Paris').closest('button');
|
|
expect(parisBtn).toBeDisabled();
|
|
});
|
|
|
|
it('FE-COMP-POLLS-012: delete button calls DELETE API and removes poll', async () => {
|
|
let deleteCalled = false;
|
|
server.use(
|
|
http.get('/api/trips/1/collab/polls', () =>
|
|
HttpResponse.json({ polls: [buildPoll({ id: 5 })] }),
|
|
),
|
|
http.delete('/api/trips/1/collab/polls/5', () => {
|
|
deleteCalled = true;
|
|
return HttpResponse.json({ success: true });
|
|
}),
|
|
);
|
|
const user = userEvent.setup();
|
|
render(<CollabPolls {...defaultProps} />);
|
|
await screen.findByText('Best destination?');
|
|
|
|
// Delete button has a title with "delete"
|
|
const deleteBtn = screen.getByRole('button', { name: /delete/i });
|
|
await user.click(deleteBtn);
|
|
|
|
await waitFor(() => expect(deleteCalled).toBe(true));
|
|
await waitFor(() =>
|
|
expect(screen.queryByText('Best destination?')).not.toBeInTheDocument(),
|
|
);
|
|
});
|
|
|
|
it('FE-COMP-POLLS-013: WebSocket collab:poll:created event adds poll', async () => {
|
|
render(<CollabPolls {...defaultProps} />);
|
|
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
|
|
|
// Get the WS listener that was registered
|
|
const listener = (addListener as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
|
listener({ tripId: 1, type: 'collab:poll:created', poll: buildPoll({ id: 77, question: 'Live poll?' }) });
|
|
|
|
expect(await screen.findByText('Live poll?')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-POLLS-014: WebSocket collab:poll:deleted event removes poll', async () => {
|
|
server.use(
|
|
http.get('/api/trips/1/collab/polls', () =>
|
|
HttpResponse.json({ polls: [buildPoll({ id: 3 })] }),
|
|
),
|
|
);
|
|
render(<CollabPolls {...defaultProps} />);
|
|
await screen.findByText('Best destination?');
|
|
|
|
const listener = (addListener as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
|
listener({ tripId: 1, type: 'collab:poll:deleted', pollId: 3 });
|
|
|
|
await waitFor(() =>
|
|
expect(screen.queryByText('Best destination?')).not.toBeInTheDocument(),
|
|
);
|
|
});
|
|
|
|
it('FE-COMP-POLLS-015: adding a third option in create modal', async () => {
|
|
const user = userEvent.setup();
|
|
render(<CollabPolls {...defaultProps} />);
|
|
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
|
await user.click(screen.getByRole('button', { name: /new/i }));
|
|
|
|
// Initially 2 option inputs
|
|
let optionInputs = screen.getAllByPlaceholderText(/option/i);
|
|
expect(optionInputs).toHaveLength(2);
|
|
|
|
// Click "Add option"
|
|
await user.click(screen.getByText(/add option/i));
|
|
|
|
optionInputs = screen.getAllByPlaceholderText(/option/i);
|
|
expect(optionInputs).toHaveLength(3);
|
|
});
|
|
});
|
|
|
|
// FE-W5CPL-001 to FE-W5CPL-028
|
|
// Covers the deadline maths, the voter chips, the error paths of every mutation
|
|
// and the WebSocket handler branches that the smoke tests above skip.
|
|
|
|
type AddToast = NonNullable<typeof window.__addToast>;
|
|
|
|
const MINUTE = 60_000;
|
|
const HOUR = 60 * MINUTE;
|
|
const DAY = 24 * HOUR;
|
|
|
|
/** A deadline `ms` in the future, with a small buffer so the clock can tick. */
|
|
const inFuture = (ms: number) => new Date(Date.now() + ms + 10_000).toISOString();
|
|
|
|
function servePolls(polls: unknown) {
|
|
server.use(http.get('/api/trips/1/collab/polls', () => HttpResponse.json(polls)));
|
|
}
|
|
|
|
/** Grabs the WS handler CollabPolls registered on mount. */
|
|
function wsHandler(): (msg: Record<string, unknown>) => void {
|
|
return (addListener as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
|
}
|
|
|
|
describe('CollabPolls details', () => {
|
|
let addToast: ReturnType<typeof vi.fn<AddToast>>;
|
|
|
|
beforeEach(() => {
|
|
addToast = vi.fn<AddToast>(() => 0);
|
|
window.__addToast = addToast;
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete window.__addToast;
|
|
});
|
|
|
|
it('FE-W5CPL-001: a deadline more than a day away is shown in days and hours', async () => {
|
|
servePolls({ polls: [buildPoll({ deadline: inFuture(2 * DAY + 3 * HOUR) })] });
|
|
render(<CollabPolls {...defaultProps} />);
|
|
expect(await screen.findByText('2d 3h')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-W5CPL-002: a deadline within the day is shown in hours and minutes', async () => {
|
|
servePolls({ polls: [buildPoll({ deadline: inFuture(5 * HOUR + 30 * MINUTE) })] });
|
|
render(<CollabPolls {...defaultProps} />);
|
|
expect(await screen.findByText('5h 30m')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-W5CPL-003: a deadline within the hour is shown in minutes', async () => {
|
|
servePolls({ polls: [buildPoll({ deadline: inFuture(45 * MINUTE) })] });
|
|
render(<CollabPolls {...defaultProps} />);
|
|
expect(await screen.findByText('45m')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-W5CPL-004: a passed deadline closes the poll and drops the countdown', async () => {
|
|
servePolls({ polls: [buildPoll({ deadline: '2020-01-01T00:00:00.000Z' })] });
|
|
render(<CollabPolls {...defaultProps} />);
|
|
await screen.findByText('Best destination?');
|
|
expect(screen.getByText(/closed/i)).toBeInTheDocument();
|
|
expect(screen.getByText('Paris').closest('button')).toBeDisabled();
|
|
});
|
|
|
|
it('FE-W5CPL-005: a poll served as a bare array is rendered', async () => {
|
|
servePolls([buildPoll({ question: 'Array shaped?' })]);
|
|
render(<CollabPolls {...defaultProps} />);
|
|
expect(await screen.findByText('Array shaped?')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-W5CPL-006: options without a voters array count as zero votes', async () => {
|
|
servePolls({ polls: [{ ...buildPoll(), options: [{ id: 1, text: 'Solo' }] }] });
|
|
render(<CollabPolls {...defaultProps} />);
|
|
await screen.findByText('Solo');
|
|
expect(screen.getByText('0 votes')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-W5CPL-007: a poll without an options array still renders its question', async () => {
|
|
servePolls({ polls: [{ ...buildPoll(), options: undefined }] });
|
|
render(<CollabPolls {...defaultProps} />);
|
|
await screen.findByText('Best destination?');
|
|
expect(screen.queryByText('Paris')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-W5CPL-008: plain string options are rendered as their own label', async () => {
|
|
servePolls({ polls: [{ ...buildPoll(), options: ['Yes', 'No'] }] });
|
|
render(<CollabPolls {...defaultProps} />);
|
|
await screen.findByText('Yes');
|
|
expect(screen.getByText('No')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-W5CPL-009: a multiple-choice poll shows the multi badge and a single vote label', async () => {
|
|
servePolls({
|
|
polls: [buildPoll({
|
|
multiple_choice: true,
|
|
options: [
|
|
{ id: 1, text: 'Paris', voters: [{ user_id: 9, username: 'bob', avatar_url: null }] },
|
|
{ id: 2, text: 'Rome', voters: [] },
|
|
],
|
|
})],
|
|
});
|
|
render(<CollabPolls {...defaultProps} />);
|
|
await screen.findByText('Best destination?');
|
|
expect(screen.getByText('Multiple choice')).toBeInTheDocument();
|
|
expect(screen.getByText('1 vote')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-W5CPL-010: once the user voted the results, avatars and tooltip appear', async () => {
|
|
servePolls({
|
|
polls: [buildPoll({
|
|
options: [
|
|
{
|
|
id: 1, text: 'Paris',
|
|
voters: [
|
|
{ user_id: 1, username: 'testuser', avatar_url: null },
|
|
{ user_id: 2, username: 'alice', avatar_url: '/uploads/avatars/alice.png' },
|
|
],
|
|
},
|
|
{ id: 2, text: 'Rome', voters: [] },
|
|
],
|
|
})],
|
|
});
|
|
render(<CollabPolls {...defaultProps} />);
|
|
await screen.findByText('Paris');
|
|
expect(screen.getByText('100%')).toBeInTheDocument();
|
|
expect(screen.getByText('0%')).toBeInTheDocument();
|
|
expect(document.querySelector('img[src="/uploads/avatars/alice.png"]')).toBeInTheDocument();
|
|
|
|
const chip = screen.getByText('T');
|
|
fireEvent.mouseEnter(chip);
|
|
expect(await screen.findByText('testuser')).toBeInTheDocument();
|
|
fireEvent.mouseLeave(chip);
|
|
await waitFor(() => expect(screen.queryByText('testuser')).not.toBeInTheDocument());
|
|
});
|
|
|
|
it('FE-W5CPL-011: a voter without a username falls back to a question mark', async () => {
|
|
servePolls({
|
|
polls: [buildPoll({
|
|
is_closed: true,
|
|
options: [
|
|
{ id: 1, text: 'Paris', voters: [{ user_id: null, username: '', avatar_url: null }] },
|
|
{ id: 2, text: 'Rome', voters: [] },
|
|
],
|
|
})],
|
|
});
|
|
render(<CollabPolls {...defaultProps} />);
|
|
await screen.findByText('Paris');
|
|
expect(screen.getByText('?')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-W5CPL-012: an open option can be voted on, a closed one cannot', async () => {
|
|
servePolls({ polls: [buildPoll({ id: 1 }), buildPoll({ id: 2, question: 'Done?', is_closed: true })] });
|
|
render(<CollabPolls {...defaultProps} />);
|
|
await screen.findByText('Done?');
|
|
const [openOption, closedOption] = screen.getAllByText('Paris').map(el => el.closest('button')!);
|
|
expect(openOption).toBeEnabled();
|
|
expect(closedOption).toBeDisabled();
|
|
});
|
|
|
|
it('FE-W5CPL-013: the closed section heading only appears next to active polls', async () => {
|
|
servePolls({
|
|
polls: [
|
|
buildPoll({ id: 1, question: 'Still open?' }),
|
|
buildPoll({ id: 2, question: 'Already done?', is_closed: true }),
|
|
],
|
|
});
|
|
render(<CollabPolls {...defaultProps} />);
|
|
await screen.findByText('Still open?');
|
|
expect(screen.getByText('Already done?')).toBeInTheDocument();
|
|
// One "Closed" badge on the poll itself plus the section heading above it
|
|
expect(screen.getAllByText('Closed')).toHaveLength(2);
|
|
});
|
|
|
|
it('FE-W5CPL-014: closing a poll marks it closed and leaves the other one open', async () => {
|
|
let closeCalled = false;
|
|
servePolls({ polls: [buildPoll({ id: 5 }), buildPoll({ id: 6, question: 'Stays open?' })] });
|
|
server.use(
|
|
http.put('/api/trips/1/collab/polls/5/close', () => {
|
|
closeCalled = true;
|
|
return HttpResponse.json({ success: true });
|
|
}),
|
|
);
|
|
const user = userEvent.setup();
|
|
render(<CollabPolls {...defaultProps} />);
|
|
await screen.findByText('Stays open?');
|
|
|
|
// Both actions sit on each poll's head band
|
|
const closeBtn = screen.getAllByRole('button', { name: 'Close' })[0];
|
|
const deleteBtn = screen.getAllByRole('button', { name: 'Delete' })[0];
|
|
expect(deleteBtn).toBeInTheDocument();
|
|
|
|
await user.click(closeBtn);
|
|
await waitFor(() => expect(closeCalled).toBe(true));
|
|
await waitFor(() => expect(screen.getAllByText('Closed')).toHaveLength(2));
|
|
expect(screen.getAllByRole('button', { name: 'Close' })).toHaveLength(1);
|
|
});
|
|
|
|
it('FE-W5CPL-027: a failing poll request falls back to the empty state', async () => {
|
|
server.use(
|
|
http.get('/api/trips/1/collab/polls', () => new HttpResponse(null, { status: 500 })),
|
|
);
|
|
render(<CollabPolls {...defaultProps} />);
|
|
expect(await screen.findByText(/no polls yet|collab\.polls\.empty/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-W5CPL-028: a payload without a polls key yields an empty list', async () => {
|
|
servePolls({});
|
|
render(<CollabPolls {...defaultProps} />);
|
|
expect(await screen.findByText(/no polls yet|collab\.polls\.empty/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-W5CPL-015: a failing close shows an error and leaves the poll open', async () => {
|
|
servePolls({ polls: [buildPoll({ id: 5 })] });
|
|
server.use(
|
|
http.put('/api/trips/1/collab/polls/5/close', () => new HttpResponse(null, { status: 500 })),
|
|
);
|
|
const user = userEvent.setup();
|
|
render(<CollabPolls {...defaultProps} />);
|
|
await screen.findByText('Best destination?');
|
|
await user.click(screen.getByRole('button', { name: /close/i }));
|
|
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
|
|
expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-W5CPL-016: a failing delete shows an error and keeps the poll', async () => {
|
|
servePolls({ polls: [buildPoll({ id: 6 })] });
|
|
server.use(
|
|
http.delete('/api/trips/1/collab/polls/6', () => new HttpResponse(null, { status: 500 })),
|
|
);
|
|
const user = userEvent.setup();
|
|
render(<CollabPolls {...defaultProps} />);
|
|
await screen.findByText('Best destination?');
|
|
await user.click(screen.getByRole('button', { name: /delete/i }));
|
|
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
|
|
expect(screen.getByText('Best destination?')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-W5CPL-017: a failing vote shows an error and leaves the tally alone', async () => {
|
|
servePolls({ polls: [buildPoll({ id: 7 })] });
|
|
server.use(
|
|
http.post('/api/trips/1/collab/polls/7/vote', () => new HttpResponse(null, { status: 500 })),
|
|
);
|
|
const user = userEvent.setup();
|
|
render(<CollabPolls {...defaultProps} />);
|
|
await screen.findByText('Paris');
|
|
await user.click(screen.getByText('Paris'));
|
|
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
|
|
expect(screen.queryByText('100%')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-W5CPL-018: an unwrapped vote response only replaces the poll that was voted on', async () => {
|
|
servePolls({ polls: [buildPoll({ id: 7 }), buildPoll({ id: 8, question: 'Untouched?' })] });
|
|
server.use(
|
|
http.post('/api/trips/1/collab/polls/7/vote', () =>
|
|
HttpResponse.json(buildPoll({
|
|
id: 7,
|
|
question: 'Voted!',
|
|
options: [
|
|
{ id: 1, text: 'Paris', voters: [{ user_id: 1, username: 'testuser', avatar_url: null }] },
|
|
{ id: 2, text: 'Rome', voters: [] },
|
|
],
|
|
})),
|
|
),
|
|
);
|
|
const user = userEvent.setup();
|
|
render(<CollabPolls {...defaultProps} />);
|
|
await screen.findByText('Untouched?');
|
|
await user.click(screen.getAllByText('Paris')[0]);
|
|
await screen.findByText('Voted!');
|
|
expect(screen.getByText('Untouched?')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-W5CPL-019: a failing create shows an error and keeps the modal open', async () => {
|
|
server.use(
|
|
http.post('/api/trips/1/collab/polls', () => new HttpResponse(null, { status: 500 })),
|
|
);
|
|
const user = userEvent.setup();
|
|
render(<CollabPolls {...defaultProps} />);
|
|
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
|
await user.click(screen.getByRole('button', { name: /new/i }));
|
|
await user.type(screen.getByPlaceholderText(/what should we do/i), 'Fails?');
|
|
const optionInputs = screen.getAllByPlaceholderText(/option/i);
|
|
await user.type(optionInputs[0], 'A');
|
|
await user.type(optionInputs[1], 'B');
|
|
await user.click(screen.getByRole('button', { name: /create|collab\.polls\.create/i }));
|
|
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
|
|
expect(screen.getByPlaceholderText(/what should we do/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-W5CPL-020: creating a poll that is already in the list does not duplicate it', async () => {
|
|
servePolls({ polls: [buildPoll({ id: 12, question: 'Same poll' })] });
|
|
server.use(
|
|
http.post('/api/trips/1/collab/polls', () =>
|
|
HttpResponse.json(buildPoll({ id: 12, question: 'Same poll' })),
|
|
),
|
|
);
|
|
const user = userEvent.setup();
|
|
render(<CollabPolls {...defaultProps} />);
|
|
await screen.findByText('Same poll');
|
|
await user.click(screen.getByRole('button', { name: /new/i }));
|
|
await user.type(screen.getByPlaceholderText(/what should we do/i), 'Same poll');
|
|
const optionInputs = screen.getAllByPlaceholderText(/option/i);
|
|
await user.type(optionInputs[0], 'A');
|
|
await user.type(optionInputs[1], 'B');
|
|
await user.click(screen.getByRole('button', { name: /create|collab\.polls\.create/i }));
|
|
await waitFor(() =>
|
|
expect(screen.queryByPlaceholderText(/what should we do/i)).not.toBeInTheDocument(),
|
|
);
|
|
expect(screen.getAllByText('Same poll')).toHaveLength(1);
|
|
});
|
|
|
|
it('FE-W5CPL-021: submitting the create form without enough options is a no-op', async () => {
|
|
let postCalled = false;
|
|
server.use(
|
|
http.post('/api/trips/1/collab/polls', () => {
|
|
postCalled = true;
|
|
return HttpResponse.json({ poll: buildPoll() });
|
|
}),
|
|
);
|
|
const user = userEvent.setup();
|
|
render(<CollabPolls {...defaultProps} />);
|
|
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
|
await user.click(screen.getByRole('button', { name: /new/i }));
|
|
await user.type(screen.getByPlaceholderText(/what should we do/i), 'Not enough');
|
|
fireEvent.submit(screen.getByPlaceholderText(/what should we do/i).closest('form')!);
|
|
await waitFor(() => expect(screen.getByPlaceholderText(/what should we do/i)).toBeInTheDocument());
|
|
expect(postCalled).toBe(false);
|
|
});
|
|
|
|
it('FE-W5CPL-022: an extra option can be removed again', async () => {
|
|
const user = userEvent.setup();
|
|
render(<CollabPolls {...defaultProps} />);
|
|
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
|
await user.click(screen.getByRole('button', { name: /new/i }));
|
|
await user.click(screen.getByText(/add option/i));
|
|
expect(screen.getAllByPlaceholderText(/option/i)).toHaveLength(3);
|
|
|
|
const thirdRow = screen.getAllByPlaceholderText(/option/i)[2].parentElement!;
|
|
await user.click(thirdRow.querySelector('button')!);
|
|
expect(screen.getAllByPlaceholderText(/option/i)).toHaveLength(2);
|
|
});
|
|
|
|
it('FE-W5CPL-023: the multi-choice toggle flips and is sent along on create', async () => {
|
|
let body: Record<string, unknown> | null = null;
|
|
server.use(
|
|
http.post('/api/trips/1/collab/polls', async ({ request }) => {
|
|
body = (await request.json()) as Record<string, unknown>;
|
|
return HttpResponse.json({ poll: buildPoll({ id: 30, question: 'Multi?' }) });
|
|
}),
|
|
);
|
|
const user = userEvent.setup();
|
|
render(<CollabPolls {...defaultProps} />);
|
|
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
|
await user.click(screen.getByRole('button', { name: /new/i }));
|
|
|
|
const toggle = screen.getByRole('switch');
|
|
expect(toggle).toHaveAttribute('aria-checked', 'false');
|
|
await user.click(toggle);
|
|
expect(toggle).toHaveAttribute('aria-checked', 'true');
|
|
|
|
await user.type(screen.getByPlaceholderText(/what should we do/i), 'Multi?');
|
|
const optionInputs = screen.getAllByPlaceholderText(/option/i);
|
|
await user.type(optionInputs[0], 'A');
|
|
await user.type(optionInputs[1], 'B');
|
|
await user.click(screen.getByRole('button', { name: /create|collab\.polls\.create/i }));
|
|
await screen.findByText('Multi?');
|
|
expect(body).toMatchObject({ multiple_choice: true, options: ['A', 'B'] });
|
|
});
|
|
|
|
it('FE-W5CPL-024: WebSocket events without a type or a known id are ignored', async () => {
|
|
servePolls({ polls: [buildPoll({ id: 40 })] });
|
|
render(<CollabPolls {...defaultProps} />);
|
|
await screen.findByText('Best destination?');
|
|
const handler = wsHandler();
|
|
await act(async () => {
|
|
handler({});
|
|
handler({ tripId: 1, type: 'collab:poll:deleted' });
|
|
handler({ tripId: 1, type: 'collab:poll:created', poll: { id: 40, question: 'Best destination?' } });
|
|
});
|
|
expect(screen.getAllByText('Best destination?')).toHaveLength(1);
|
|
});
|
|
|
|
it('FE-W5CPL-025: WebSocket vote and close events update only the matching poll', async () => {
|
|
servePolls({ polls: [buildPoll({ id: 41 }), buildPoll({ id: 42, question: 'Other poll' })] });
|
|
render(<CollabPolls {...defaultProps} />);
|
|
await screen.findByText('Other poll');
|
|
const handler = wsHandler();
|
|
|
|
await act(async () => {
|
|
handler({ tripId: 1, type: 'collab:poll:voted', poll: buildPoll({ id: 41, question: 'Voted live' }) });
|
|
});
|
|
expect(await screen.findByText('Voted live')).toBeInTheDocument();
|
|
expect(screen.getByText('Other poll')).toBeInTheDocument();
|
|
|
|
await act(async () => {
|
|
handler({ tripId: 1, type: 'collab:poll:closed', poll: { id: 41 } });
|
|
});
|
|
await waitFor(() => expect(screen.getAllByText('Closed')).toHaveLength(2));
|
|
|
|
await act(async () => {
|
|
handler({ tripId: 1, type: 'collab:poll:deleted', poll: { id: 42 } });
|
|
});
|
|
await waitFor(() => expect(screen.queryByText('Other poll')).not.toBeInTheDocument());
|
|
});
|
|
|
|
it('FE-W5CPL-027: a WebSocket event for another trip is ignored', async () => {
|
|
servePolls({ polls: [] });
|
|
render(<CollabPolls {...defaultProps} />);
|
|
await screen.findByText(/no polls yet|collab.polls.empty/i);
|
|
const handler = wsHandler();
|
|
await act(async () => {
|
|
handler({ tripId: 2, type: 'collab:poll:created', poll: buildPoll({ id: 88, question: 'Other trip poll?' }) });
|
|
});
|
|
expect(screen.queryByText('Other trip poll?')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-W5CPL-028: a slow load for the trip we left does not overwrite the new one', async () => {
|
|
server.use(
|
|
http.get('/api/trips/1/collab/polls', async () => {
|
|
await delay(80);
|
|
return HttpResponse.json({ polls: [buildPoll({ id: 1, question: 'Trip one poll?' })] });
|
|
}),
|
|
http.get('/api/trips/2/collab/polls', () =>
|
|
HttpResponse.json({ polls: [buildPoll({ id: 2, question: 'Trip two poll?' })] }),
|
|
),
|
|
);
|
|
const { rerender } = render(<CollabPolls {...defaultProps} />);
|
|
rerender(<CollabPolls tripId={2} currentUser={currentUser} />);
|
|
|
|
await screen.findByText('Trip two poll?');
|
|
await act(async () => { await delay(150); });
|
|
expect(screen.queryByText('Trip one poll?')).not.toBeInTheDocument();
|
|
expect(screen.getByText('Trip two poll?')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-W5CPL-026: a poll with a live deadline starts the countdown ticker', async () => {
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
try {
|
|
servePolls({ polls: [buildPoll({ deadline: inFuture(90 * MINUTE) })] });
|
|
const { unmount } = render(<CollabPolls {...defaultProps} />);
|
|
await screen.findByText('1h 30m');
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(31_000); });
|
|
expect(screen.getByText('1h 29m')).toBeInTheDocument();
|
|
unmount();
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|
|
|
|
// FE-COMP-POLLS-016 to FE-COMP-POLLS-022
|
|
// Markdown questions, sanitization and multiline input (#2177).
|
|
|
|
describe('CollabPolls markdown & multiline', () => {
|
|
it('FE-COMP-POLLS-016: a markdown question renders headings and bold text', async () => {
|
|
server.use(
|
|
http.get('/api/trips/1/collab/polls', () =>
|
|
HttpResponse.json({ polls: [buildPoll({ question: '## Day trip\n\n**Vote** carefully' })] }),
|
|
),
|
|
);
|
|
render(<CollabPolls {...defaultProps} />);
|
|
const heading = await screen.findByText('Day trip');
|
|
expect(heading.closest('h2')).not.toBeNull();
|
|
expect(screen.getByText('Vote').closest('strong')).not.toBeNull();
|
|
});
|
|
|
|
it('FE-COMP-POLLS-017: a script/onerror injection attempt produces no live DOM nodes', async () => {
|
|
server.use(
|
|
http.get('/api/trips/1/collab/polls', () =>
|
|
HttpResponse.json({
|
|
polls: [buildPoll({ question: 'Safe title <script>alert("xss")</script> <img src=x onerror=alert(1)>' })],
|
|
}),
|
|
),
|
|
);
|
|
render(<CollabPolls {...defaultProps} />);
|
|
await screen.findByText(/Safe title/);
|
|
expect(document.querySelector('script')).toBeNull();
|
|
expect(document.querySelector('img[src="x"]')).toBeNull();
|
|
expect(document.querySelector('[onerror]')).toBeNull();
|
|
});
|
|
|
|
it('FE-COMP-POLLS-018: blank lines split the question into separate paragraphs', async () => {
|
|
server.use(
|
|
http.get('/api/trips/1/collab/polls', () =>
|
|
HttpResponse.json({ polls: [buildPoll({ question: 'First paragraph\n\nSecond paragraph' })] }),
|
|
),
|
|
);
|
|
render(<CollabPolls {...defaultProps} />);
|
|
const first = await screen.findByText('First paragraph');
|
|
const second = screen.getByText('Second paragraph');
|
|
expect(first.tagName).toBe('P');
|
|
expect(second.tagName).toBe('P');
|
|
expect(first).not.toBe(second);
|
|
});
|
|
|
|
it('FE-COMP-POLLS-019: a plain-text question renders identically, without formatting nodes', async () => {
|
|
server.use(
|
|
http.get('/api/trips/1/collab/polls', () =>
|
|
HttpResponse.json({ polls: [buildPoll({ question: 'Best destination?' })] }),
|
|
),
|
|
);
|
|
render(<CollabPolls {...defaultProps} />);
|
|
const question = await screen.findByText('Best destination?');
|
|
expect(question.textContent).toBe('Best destination?');
|
|
// The question sits in a paragraph now, but no formatting was invented
|
|
expect(question.parentElement!.querySelector('strong, em, h1, h2, a')).toBeNull();
|
|
});
|
|
|
|
it('FE-COMP-POLLS-020: the question field is a textarea — Enter adds a line instead of submitting', async () => {
|
|
let body: Record<string, unknown> | null = null;
|
|
server.use(
|
|
http.post('/api/trips/1/collab/polls', async ({ request }) => {
|
|
body = (await request.json()) as Record<string, unknown>;
|
|
return HttpResponse.json({ poll: buildPoll({ id: 60, question: 'Line one\nLine two' }) });
|
|
}),
|
|
);
|
|
const user = userEvent.setup();
|
|
render(<CollabPolls {...defaultProps} />);
|
|
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
|
await user.click(screen.getByRole('button', { name: /new/i }));
|
|
|
|
const questionField = screen.getByPlaceholderText(/what should we do/i);
|
|
expect(questionField.tagName).toBe('TEXTAREA');
|
|
await user.type(questionField, 'Line one{enter}Line two');
|
|
// Enter stayed in the field — the form was not submitted without options
|
|
expect(body).toBeNull();
|
|
expect(questionField).toHaveValue('Line one\nLine two');
|
|
|
|
const optionInputs = screen.getAllByPlaceholderText(/option/i);
|
|
expect(optionInputs[0].tagName).toBe('TEXTAREA');
|
|
await user.type(optionInputs[0], 'A');
|
|
await user.type(optionInputs[1], 'B');
|
|
await user.click(screen.getByRole('button', { name: /create|collab\.polls\.create/i }));
|
|
await waitFor(() => expect(body).toMatchObject({ question: 'Line one\nLine two' }));
|
|
});
|
|
|
|
it('FE-COMP-POLLS-021: a link in the question opens in a new tab with rel protection', async () => {
|
|
server.use(
|
|
http.get('/api/trips/1/collab/polls', () =>
|
|
HttpResponse.json({ polls: [buildPoll({ question: 'Where? [Our hotel](https://example.com)' })] }),
|
|
),
|
|
);
|
|
render(<CollabPolls {...defaultProps} />);
|
|
const link = await screen.findByRole('link', { name: 'Our hotel' });
|
|
expect(link).toHaveAttribute('href', 'https://example.com');
|
|
expect(link).toHaveAttribute('target', '_blank');
|
|
expect(link).toHaveAttribute('rel', 'noopener noreferrer nofollow');
|
|
});
|
|
|
|
it('FE-COMP-POLLS-022: a long multiline option wraps instead of being clipped', async () => {
|
|
server.use(
|
|
http.get('/api/trips/1/collab/polls', () =>
|
|
HttpResponse.json({
|
|
polls: [buildPoll({
|
|
options: [
|
|
{ id: 1, text: 'Stay at the beach house\nwith the long unpronounceable name', voters: [] },
|
|
{ id: 2, text: 'Rome', voters: [] },
|
|
],
|
|
})],
|
|
}),
|
|
),
|
|
);
|
|
render(<CollabPolls {...defaultProps} />);
|
|
const label = (await screen.findByText(/Stay at the beach house/)).closest('span')!;
|
|
expect(label.style.whiteSpace).toBe('pre-wrap');
|
|
expect(label.style.overflowWrap).toBe('anywhere');
|
|
expect(label.style.minWidth).toBe('0px');
|
|
});
|
|
|
|
it('FE-COMP-POLLS-023: a placeholder in angle brackets survives sanitizing', async () => {
|
|
server.use(
|
|
http.get('/api/trips/1/collab/polls', () =>
|
|
HttpResponse.json({ polls: [buildPoll({ question: 'Treffpunkt <noch offen> oder Hotel?' })] }),
|
|
),
|
|
);
|
|
render(<CollabPolls {...defaultProps} />);
|
|
// Questions written before markdown rendering existed must keep reading the
|
|
// way they were typed, and they cannot be edited afterwards (#2177).
|
|
expect(await screen.findByText('Treffpunkt <noch offen> oder Hotel?')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-POLLS-024: a script tag is shown as text, never as an element', async () => {
|
|
server.use(
|
|
http.get('/api/trips/1/collab/polls', () =>
|
|
HttpResponse.json({ polls: [buildPoll({ question: 'Plan <script>alert("xss")</script>' })] }),
|
|
),
|
|
);
|
|
render(<CollabPolls {...defaultProps} />);
|
|
expect(await screen.findByText('Plan <script>alert("xss")</script>')).toBeInTheDocument();
|
|
expect(document.querySelector('script')).toBeNull();
|
|
});
|
|
});
|