mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
35eba21128
A screen reader lists a page's buttons together, and the costs views gave
it twenty "Edit" and "Delete" buttons with nothing to tell them apart.
- Edit and delete on an expense name the expense ("Edit: Dinner"), edit
and undo on a payment name who paid whom, on desktop and phone.
- A settle-up row shows only initials in its avatars, its names lived in a
tooltip; the row now carries them for assistive tech too, and its Settle
button says who pays whom and how much.
- The search field of every searchable select (currency, time zone,
category) was read out as "..."; it now says Search in the user's
language.
- The public link toggles on the phone share sheet and the journey share
toggles say whether they are on (aria-pressed), not only by colour.
- Japanese still said "you" in the settle-up lines.
Refs #2604
1164 lines
50 KiB
TypeScript
1164 lines
50 KiB
TypeScript
// FE-COMP-MEMBERS-001 to FE-COMP-MEMBERS-060
|
||
import type { Mock } from 'vitest';
|
||
import { act, render, screen, fireEvent, waitFor } from '../../../tests/helpers/render';
|
||
import userEvent from '@testing-library/user-event';
|
||
import { delay, http, HttpResponse } from 'msw';
|
||
import { server } from '../../../tests/helpers/msw/server';
|
||
import { useAuthStore } from '../../store/authStore';
|
||
import { useTripStore } from '../../store/tripStore';
|
||
import { usePermissionsStore } from '../../store/permissionsStore';
|
||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||
import { buildUser, buildTrip } from '../../../tests/helpers/factories';
|
||
import TripMembersModal from './TripMembersModal';
|
||
|
||
const defaultProps = {
|
||
isOpen: true,
|
||
onClose: vi.fn(),
|
||
tripId: 1,
|
||
tripTitle: 'Test Trip',
|
||
};
|
||
|
||
const ownerUser = buildUser({ id: 1, username: 'owner' });
|
||
const memberUser = buildUser({ id: 2, username: 'alice' });
|
||
|
||
type AddToast = NonNullable<typeof window.__addToast>;
|
||
let addToast: Mock<AddToast>;
|
||
|
||
interface MemberRow {
|
||
id: number;
|
||
username: string;
|
||
avatar_url?: string | null;
|
||
is_guest?: boolean;
|
||
}
|
||
|
||
/** Replaces the roster handler; `owner` defaults to the seeded owner user. */
|
||
function mockRoster(members: MemberRow[], owner: MemberRow = { id: ownerUser.id, username: ownerUser.username, avatar_url: null }): void {
|
||
server.use(
|
||
http.get('/api/trips/1/members', () =>
|
||
HttpResponse.json({ owner, members, current_user_id: ownerUser.id })
|
||
),
|
||
);
|
||
}
|
||
|
||
/** Owner-only permissions so the share/invite column renders for the seeded owner. */
|
||
function asShareOwner(): void {
|
||
seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } });
|
||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) });
|
||
server.use(http.get('/api/trips/1/invite-link', () => HttpResponse.json({ token: null })));
|
||
}
|
||
|
||
/** A browser on https, where the async clipboard API is the path taken. */
|
||
function mockClipboard(): Mock<(text: string) => Promise<void>> {
|
||
const writeText = vi.fn(async () => {});
|
||
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true, writable: true });
|
||
Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true, writable: true });
|
||
return writeText;
|
||
}
|
||
|
||
/**
|
||
* A `location` whose `reload` is a spy.
|
||
*
|
||
* jsdom's own `reload` cannot be redefined, so the whole object has to be
|
||
* replaced, and `{ ...window.location }` is the trap: its fields are getters on
|
||
* the prototype, so the copy has no `href` for axios to resolve a relative URL
|
||
* against and the request never reaches MSW. The fields are therefore read out
|
||
* by name.
|
||
*/
|
||
/**
|
||
* A `location` whose `reload` is a spy, for the two flows that reload the app.
|
||
*
|
||
* jsdom's own `reload` cannot be redefined, so the whole object is replaced, and
|
||
* the replacement is a flat copy without the prototype's getters. That is enough
|
||
* for a test that only asserts the reload, but it leaves a request with nothing
|
||
* to resolve a relative URL against, so a test that asserts the call itself must
|
||
* not use this. The original is put back after every test: left in place, it
|
||
* broke every later case in the file.
|
||
*/
|
||
let realLocation: PropertyDescriptor | undefined;
|
||
function stubReload(): Mock<() => void> {
|
||
const reload = vi.fn();
|
||
realLocation ??= Object.getOwnPropertyDescriptor(window, 'location');
|
||
Object.defineProperty(window, 'location', { value: { ...window.location, reload }, writable: true, configurable: true });
|
||
return reload;
|
||
}
|
||
|
||
beforeEach(() => {
|
||
resetAllStores();
|
||
server.use(
|
||
http.get('/api/trips/1/members', () =>
|
||
HttpResponse.json({
|
||
owner: { id: ownerUser.id, username: ownerUser.username, avatar_url: null },
|
||
members: [],
|
||
current_user_id: ownerUser.id,
|
||
})
|
||
),
|
||
http.get('/api/trips/1/share-link', () =>
|
||
HttpResponse.json({ token: null })
|
||
),
|
||
http.get('/api/auth/users', () =>
|
||
HttpResponse.json({ users: [memberUser] })
|
||
),
|
||
);
|
||
seedStore(useAuthStore, { user: ownerUser, isAuthenticated: true });
|
||
seedStore(useTripStore, { trip: buildTrip({ id: 1, title: 'Test Trip' }) });
|
||
addToast = vi.fn<AddToast>(() => 0);
|
||
window.__addToast = addToast;
|
||
});
|
||
|
||
afterEach(() => {
|
||
delete window.__addToast;
|
||
vi.useRealTimers();
|
||
vi.restoreAllMocks();
|
||
if (realLocation) {
|
||
Object.defineProperty(window, 'location', realLocation);
|
||
realLocation = undefined;
|
||
}
|
||
Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true, writable: true });
|
||
});
|
||
|
||
describe('TripMembersModal', () => {
|
||
it('FE-COMP-MEMBERS-001: renders without crashing', () => {
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
expect(document.body).toBeInTheDocument();
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-002: shows Share Trip title', () => {
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
// members.shareTrip = "Share Trip"
|
||
expect(screen.getByText('Share Trip')).toBeInTheDocument();
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-003: shows owner username after load', async () => {
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
expect(await screen.findByText('owner')).toBeInTheDocument();
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-004: shows Owner label', async () => {
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
expect(await screen.findByText('Owner')).toBeInTheDocument();
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-005: shows Access section heading', async () => {
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
// Text is "Access (1 person)" so use regex
|
||
expect(await screen.findByText(/Access/i)).toBeInTheDocument();
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-006: shows member when members are loaded', async () => {
|
||
server.use(
|
||
http.get('/api/trips/1/members', () =>
|
||
HttpResponse.json({
|
||
owner: { id: ownerUser.id, username: ownerUser.username, avatar_url: null },
|
||
members: [{ id: memberUser.id, username: memberUser.username, avatar_url: null }],
|
||
current_user_id: ownerUser.id,
|
||
})
|
||
)
|
||
);
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
expect(await screen.findByText('alice')).toBeInTheDocument();
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-007: shows Invite User section', async () => {
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
expect(await screen.findByText('Invite User')).toBeInTheDocument();
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-008: shows Invite button', async () => {
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
expect(await screen.findByRole('button', { name: /Invite/i })).toBeInTheDocument();
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-009: Cancel/close button is present', () => {
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
// Modal has a close button (×)
|
||
const closeBtn = screen.queryByRole('button', { name: /close/i }) || document.querySelector('[aria-label="close"], button[title="Close"]');
|
||
// The modal renders at minimum a close button or can be closed by clicking overlay
|
||
expect(document.body).toBeInTheDocument();
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-010: shows member count of 1 with owner', async () => {
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
// 1 person (just owner)
|
||
expect(await screen.findByText(/1 person/i)).toBeInTheDocument();
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-011: members count increases when member is added', async () => {
|
||
server.use(
|
||
http.get('/api/trips/1/members', () =>
|
||
HttpResponse.json({
|
||
owner: { id: ownerUser.id, username: ownerUser.username, avatar_url: null },
|
||
members: [{ id: memberUser.id, username: memberUser.username, avatar_url: null }],
|
||
current_user_id: ownerUser.id,
|
||
})
|
||
)
|
||
);
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
expect(await screen.findByText(/2 people/i)).toBeInTheDocument();
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-012: shows "you" label next to current user', async () => {
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
// A badge beside the name on the desktop; the phone keeps "(you)".
|
||
expect(await screen.findByText('you')).toBeInTheDocument();
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-013: shows remove access button for members (not owner)', async () => {
|
||
server.use(
|
||
http.get('/api/trips/1/members', () =>
|
||
HttpResponse.json({
|
||
owner: { id: ownerUser.id, username: ownerUser.username, avatar_url: null },
|
||
members: [{ id: memberUser.id, username: memberUser.username, avatar_url: null }],
|
||
current_user_id: ownerUser.id,
|
||
})
|
||
)
|
||
);
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
await screen.findByText('alice');
|
||
// Remove access button shown for members
|
||
expect(screen.getByRole('button', { name: 'Remove access' })).toBeInTheDocument();
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-014: remove member calls DELETE API', async () => {
|
||
const user = userEvent.setup();
|
||
let deleteCalled = false;
|
||
server.use(
|
||
http.get('/api/trips/1/members', () =>
|
||
HttpResponse.json({
|
||
owner: { id: ownerUser.id, username: ownerUser.username, avatar_url: null },
|
||
members: [{ id: memberUser.id, username: memberUser.username, avatar_url: null }],
|
||
current_user_id: ownerUser.id,
|
||
})
|
||
),
|
||
http.delete('/api/trips/1/members/:userId', () => {
|
||
deleteCalled = true;
|
||
return HttpResponse.json({ success: true });
|
||
})
|
||
);
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
await screen.findByText('alice');
|
||
await user.click(screen.getByRole('button', { name: 'Remove access' }));
|
||
// The question comes first, in the app's own dialog.
|
||
expect(screen.getByText('Remove access for this user?')).toBeInTheDocument();
|
||
await user.click(screen.getByRole('button', { name: 'Confirm' }));
|
||
await waitFor(() => expect(deleteCalled).toBe(true));
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-015: modal renders when isOpen is true', () => {
|
||
render(<TripMembersModal {...defaultProps} isOpen={true} />);
|
||
expect(screen.getByText('Share Trip')).toBeInTheDocument();
|
||
});
|
||
|
||
// ── Share Link Section (016-021) ───────────────────────────────────────────
|
||
|
||
it('FE-COMP-MEMBERS-016: share link section not rendered for non-owner', async () => {
|
||
const nonOwner = buildUser({ id: 99, username: 'stranger' });
|
||
seedStore(useAuthStore, { user: nonOwner, isAuthenticated: true });
|
||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 1 }) });
|
||
seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } });
|
||
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
// Wait for members list to load so the component is fully rendered
|
||
await screen.findByText(/Access/i);
|
||
expect(screen.queryByText('Public Link')).not.toBeInTheDocument();
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-017: share link section visible for owner', async () => {
|
||
seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } });
|
||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) });
|
||
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
expect(await screen.findByText('Public Link')).toBeInTheDocument();
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-018: create share link shows URL after clicking create', async () => {
|
||
const user = userEvent.setup();
|
||
seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } });
|
||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) });
|
||
|
||
// GET returns null token initially; POST returns a new token
|
||
server.use(
|
||
http.get('/api/trips/1/share-link', () => HttpResponse.json({ token: null })),
|
||
http.post('/api/trips/1/share-link', () =>
|
||
HttpResponse.json({
|
||
token: 'abc123',
|
||
share_map: true,
|
||
share_bookings: true,
|
||
share_packing: false,
|
||
share_budget: false,
|
||
share_collab: false,
|
||
})
|
||
),
|
||
);
|
||
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
const createBtn = await screen.findByText('Create link');
|
||
await user.click(createBtn);
|
||
|
||
await waitFor(() => {
|
||
const input = screen.getByDisplayValue(/\/shared\/abc123/);
|
||
expect(input).toBeInTheDocument();
|
||
});
|
||
// Each toggle says whether it is on, not only by its colour (#2604).
|
||
expect(screen.getByRole('button', { name: 'Bookings' })).toHaveAttribute('aria-pressed', 'true');
|
||
expect(screen.getByRole('button', { name: 'Costs' })).toHaveAttribute('aria-pressed', 'false');
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-019: copy share link calls clipboard.writeText', async () => {
|
||
const user = userEvent.setup();
|
||
seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } });
|
||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) });
|
||
|
||
const writeText = mockClipboard();
|
||
|
||
server.use(
|
||
http.get('/api/trips/1/share-link', () =>
|
||
HttpResponse.json({
|
||
token: 'tok99',
|
||
share_map: true,
|
||
share_bookings: true,
|
||
share_packing: false,
|
||
share_budget: false,
|
||
share_collab: false,
|
||
})
|
||
),
|
||
);
|
||
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
const copyBtn = await screen.findByText('Copy');
|
||
await user.click(copyBtn);
|
||
|
||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('tok99'));
|
||
await screen.findByText('Copied');
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-020: delete share link removes URL and shows create button', async () => {
|
||
const user = userEvent.setup();
|
||
seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } });
|
||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) });
|
||
|
||
let deleteHandlerCalled = false;
|
||
server.use(
|
||
http.get('/api/trips/1/share-link', () =>
|
||
HttpResponse.json({
|
||
token: 'tok99',
|
||
share_map: true,
|
||
share_bookings: true,
|
||
share_packing: false,
|
||
share_budget: false,
|
||
share_collab: false,
|
||
})
|
||
),
|
||
http.delete('/api/trips/1/share-link', () => {
|
||
deleteHandlerCalled = true;
|
||
return HttpResponse.json({ success: true });
|
||
}),
|
||
);
|
||
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
const deleteBtn = await screen.findByText('Delete link');
|
||
await user.click(deleteBtn);
|
||
|
||
expect(deleteHandlerCalled).toBe(true);
|
||
await screen.findByText('Create link');
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-021: clicking permission toggle calls POST with updated perms', async () => {
|
||
const user = userEvent.setup();
|
||
seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } });
|
||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) });
|
||
|
||
let postedPerms: Record<string, unknown> | null = null;
|
||
server.use(
|
||
http.get('/api/trips/1/share-link', () =>
|
||
HttpResponse.json({
|
||
token: 'tok99',
|
||
share_map: true,
|
||
share_bookings: true,
|
||
share_packing: false,
|
||
share_budget: false,
|
||
share_collab: false,
|
||
})
|
||
),
|
||
http.post('/api/trips/1/share-link', async ({ request }) => {
|
||
postedPerms = await request.json() as Record<string, unknown>;
|
||
return HttpResponse.json({ token: 'tok99', ...postedPerms });
|
||
}),
|
||
);
|
||
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
// Wait for the share section to load
|
||
await screen.findByText('Public Link');
|
||
// Click the "Packing" permission pill to toggle it on
|
||
const packingBtn = await screen.findByText('Packing');
|
||
await user.click(packingBtn);
|
||
|
||
await waitFor(() => {
|
||
expect(postedPerms).not.toBeNull();
|
||
expect(postedPerms).toMatchObject({ share_packing: true });
|
||
});
|
||
});
|
||
|
||
// ── Member management (022-025) ────────────────────────────────────────────
|
||
|
||
it('FE-COMP-MEMBERS-022: adding a member via select + invite calls POST', async () => {
|
||
const user = userEvent.setup();
|
||
let postBody: Record<string, unknown> | null = null;
|
||
server.use(
|
||
http.post('/api/trips/1/members', async ({ request }) => {
|
||
postBody = await request.json() as Record<string, unknown>;
|
||
return HttpResponse.json({ success: true });
|
||
}),
|
||
);
|
||
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
// Wait for Invite section to load
|
||
await screen.findByText('Invite User');
|
||
|
||
// Open the CustomSelect by clicking its trigger button (shows placeholder)
|
||
const selectTrigger = screen.getByText('Select user…');
|
||
await user.click(selectTrigger);
|
||
|
||
// alice option appears in the portal dropdown
|
||
const aliceOption = await screen.findByRole('button', { name: 'alice' });
|
||
await user.click(aliceOption);
|
||
|
||
// Click the member "Invite" button (exact — the Share area also has a
|
||
// "Create invite link" button that a loose /Invite/i would match too).
|
||
const inviteBtn = screen.getByRole('button', { name: 'Invite' });
|
||
await user.click(inviteBtn);
|
||
|
||
await waitFor(() => {
|
||
expect(postBody).not.toBeNull();
|
||
});
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-023: invite button is disabled when no user is selected', async () => {
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
await screen.findByText('Invite User');
|
||
|
||
const inviteBtn = screen.getByRole('button', { name: /Invite/i });
|
||
expect(inviteBtn).toBeDisabled();
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-024: leave trip calls DELETE for current user', async () => {
|
||
const user = userEvent.setup();
|
||
// No reload stub here on purpose: this case asserts the DELETE, and a stubbed
|
||
// location is exactly what stops the request from reaching it. jsdom's own
|
||
// reload is a no-op that only logs.
|
||
|
||
seedStore(useAuthStore, { user: memberUser, isAuthenticated: true });
|
||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) });
|
||
|
||
let deleteCalledForUserId: string | null = null;
|
||
server.use(
|
||
http.get('/api/trips/1/members', () =>
|
||
HttpResponse.json({
|
||
owner: { id: ownerUser.id, username: ownerUser.username, avatar_url: null },
|
||
members: [{ id: memberUser.id, username: memberUser.username, avatar_url: null }],
|
||
current_user_id: memberUser.id,
|
||
})
|
||
),
|
||
http.delete('/api/trips/1/members/:userId', ({ params }) => {
|
||
deleteCalledForUserId = params.userId as string;
|
||
return HttpResponse.json({ success: true });
|
||
}),
|
||
);
|
||
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
await screen.findByText('alice');
|
||
|
||
await user.click(screen.getByRole('button', { name: 'Leave trip' }));
|
||
expect(screen.getByText('Leave trip? You will lose access.')).toBeInTheDocument();
|
||
await user.click(screen.getByRole('button', { name: 'Confirm' }));
|
||
|
||
await waitFor(() => {
|
||
expect(deleteCalledForUserId).toBe(String(memberUser.id));
|
||
});
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-025: "all have access" message shown when all users are members', async () => {
|
||
server.use(
|
||
http.get('/api/trips/1/members', () =>
|
||
HttpResponse.json({
|
||
owner: { id: ownerUser.id, username: ownerUser.username, avatar_url: null },
|
||
members: [{ id: memberUser.id, username: memberUser.username, avatar_url: null }],
|
||
current_user_id: ownerUser.id,
|
||
})
|
||
),
|
||
http.get('/api/auth/users', () =>
|
||
HttpResponse.json({ users: [memberUser] })
|
||
),
|
||
);
|
||
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
expect(await screen.findByText('All users already have access.')).toBeInTheDocument();
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-026: owner sees the guests section and can add a guest (#1362)', async () => {
|
||
let createdName: string | null = null;
|
||
server.use(
|
||
http.post('/api/trips/1/guests', async ({ request }) => {
|
||
createdName = ((await request.json()) as { name: string }).name;
|
||
return HttpResponse.json({ member: { id: 99, username: createdName, is_guest: true } });
|
||
}),
|
||
);
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
// The guests section + add affordance is shown to the owner.
|
||
await screen.findByText('Guests');
|
||
const input = screen.getByPlaceholderText('Guest name');
|
||
await userEvent.type(input, 'Grandpa');
|
||
await userEvent.click(screen.getByRole('button', { name: /Add guest/i }));
|
||
await waitFor(() => expect(createdName).toBe('Grandpa'));
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-027: a guest member is shown in the guests section with a Guest badge, not the members list (#1362)', async () => {
|
||
server.use(
|
||
http.get('/api/trips/1/members', () =>
|
||
HttpResponse.json({
|
||
owner: { id: ownerUser.id, username: ownerUser.username, avatar_url: null, is_guest: false },
|
||
members: [
|
||
{ id: 2, username: 'alice', avatar_url: null, is_guest: false },
|
||
{ id: 3, username: 'Grandma', avatar_url: null, is_guest: true },
|
||
],
|
||
current_user_id: ownerUser.id,
|
||
})
|
||
),
|
||
);
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
await screen.findByText('Grandma');
|
||
// The guest carries a "Guest" badge.
|
||
expect(screen.getAllByText('Guest').length).toBeGreaterThan(0);
|
||
// The head band counts the owner and the real member (2), not the guest.
|
||
expect(screen.getByText('2 people')).toBeInTheDocument();
|
||
});
|
||
|
||
// ── Avatars and load failures (028-031) ───────────────────────────────────
|
||
|
||
it('FE-COMP-MEMBERS-028: a member with an avatar renders the image instead of the initial', async () => {
|
||
mockRoster([{ id: 2, username: 'alice', avatar_url: '/uploads/avatars/alice.png' }]);
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
|
||
await screen.findByText('alice');
|
||
expect(document.querySelector('img[src="/uploads/avatars/alice.png"]')).toBeInTheDocument();
|
||
// The owner has no avatar and still gets a letter tile.
|
||
expect(screen.getByText('O')).toBeInTheDocument();
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-029: a nameless member falls back to a question mark', async () => {
|
||
mockRoster([], { id: ownerUser.id, username: '', avatar_url: null });
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
|
||
expect(await screen.findByText('?')).toBeInTheDocument();
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-030: a failing roster load shows an error toast and an empty list', async () => {
|
||
server.use(http.get('/api/trips/1/members', () => HttpResponse.json({}, { status: 500 })));
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
|
||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to load members', 'error', undefined));
|
||
expect(screen.getByText('0 people')).toBeInTheDocument();
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-031: a failing share-link load still offers link creation', async () => {
|
||
asShareOwner();
|
||
server.use(http.get('/api/trips/1/share-link', () => HttpResponse.json({}, { status: 500 })));
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
|
||
await screen.findByText('Create link');
|
||
expect(screen.getByText('Public Link')).toBeInTheDocument();
|
||
});
|
||
|
||
// ── Share link errors and defaults (032-036) ──────────────────────────────
|
||
|
||
it('FE-COMP-MEMBERS-032: a failing share-link creation is reported', async () => {
|
||
const user = userEvent.setup();
|
||
asShareOwner();
|
||
server.use(http.post('/api/trips/1/share-link', () => HttpResponse.json({}, { status: 500 })));
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
|
||
await user.click(await screen.findByText('Create link'));
|
||
|
||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Could not create link', 'error', undefined));
|
||
expect(screen.getByText('Create link')).toBeInTheDocument();
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-033: missing permission flags fall back to map+bookings, and Map is not toggleable', async () => {
|
||
const user = userEvent.setup();
|
||
asShareOwner();
|
||
let postedPerms: Record<string, unknown> | null = null;
|
||
server.use(
|
||
http.get('/api/trips/1/share-link', () => HttpResponse.json({ token: 'tok77' })),
|
||
http.post('/api/trips/1/share-link', async ({ request }) => {
|
||
postedPerms = (await request.json()) as Record<string, unknown>;
|
||
return HttpResponse.json({ token: 'tok77', ...postedPerms });
|
||
}),
|
||
);
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
|
||
// The always-on Map & Plan pill ignores clicks.
|
||
await user.click(await screen.findByText('Map & Plan'));
|
||
expect(postedPerms).toBeNull();
|
||
|
||
await user.click(screen.getByText('Costs'));
|
||
await waitFor(() => expect(postedPerms).toMatchObject({
|
||
share_map: true, share_bookings: true, share_packing: false, share_budget: true, share_collab: false,
|
||
}));
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-033b: the two narrowing options post with the link (#1712)', async () => {
|
||
const user = userEvent.setup();
|
||
asShareOwner();
|
||
let postedPerms: Record<string, unknown> | null = null;
|
||
server.use(
|
||
http.get('/api/trips/1/share-link', () => HttpResponse.json({ token: 'tok77', share_hide_images: true })),
|
||
http.post('/api/trips/1/share-link', async ({ request }) => {
|
||
postedPerms = (await request.json()) as Record<string, unknown>;
|
||
return HttpResponse.json({ token: 'tok77' });
|
||
}),
|
||
);
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
|
||
await user.click(await screen.findByText('Travel & stays only'));
|
||
await waitFor(() => expect(postedPerms).toMatchObject({ share_travel_only: true, share_hide_images: true }));
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-034: a failing permission update is reported', async () => {
|
||
const user = userEvent.setup();
|
||
asShareOwner();
|
||
server.use(
|
||
http.get('/api/trips/1/share-link', () => HttpResponse.json({ token: 'tok77' })),
|
||
http.post('/api/trips/1/share-link', () => HttpResponse.json({}, { status: 500 })),
|
||
);
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
|
||
await user.click(await screen.findByText('Packing'));
|
||
|
||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Could not create link', 'error', undefined));
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-035: a failing share-link deletion keeps the link', async () => {
|
||
const user = userEvent.setup();
|
||
asShareOwner();
|
||
server.use(
|
||
http.get('/api/trips/1/share-link', () => HttpResponse.json({ token: 'tok77' })),
|
||
http.delete('/api/trips/1/share-link', () => HttpResponse.json({}, { status: 500 })),
|
||
);
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
|
||
await user.click(await screen.findByText('Delete link'));
|
||
|
||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
|
||
expect(screen.getByDisplayValue(/\/shared\/tok77/)).toBeInTheDocument();
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-036: the copied badge resets two seconds after the last copy', async () => {
|
||
asShareOwner();
|
||
const writeText = mockClipboard();
|
||
server.use(http.get('/api/trips/1/share-link', () => HttpResponse.json({ token: 'tok77' })));
|
||
const view = render(<TripMembersModal {...defaultProps} />);
|
||
|
||
const copyBtn = (await screen.findByText('Copy')).closest('button')!;
|
||
|
||
vi.useFakeTimers();
|
||
fireEvent.click(copyBtn);
|
||
// The copy is awaited now, so the badge lands a microtask after the click.
|
||
await act(async () => {});
|
||
expect(screen.getByText('Copied')).toBeInTheDocument();
|
||
// A second copy replaces the pending reset rather than stacking timers.
|
||
fireEvent.click(screen.getByText('Copied').closest('button')!);
|
||
await act(async () => {});
|
||
act(() => { vi.advanceTimersByTime(2000); });
|
||
|
||
expect(screen.getByText('Copy')).toBeInTheDocument();
|
||
expect(writeText).toHaveBeenCalledTimes(2);
|
||
view.unmount();
|
||
});
|
||
|
||
// ── Trip invite link (037-039) ────────────────────────────────────────────
|
||
|
||
it('FE-COMP-MEMBERS-037: an invite link can be created, copied, regenerated and disabled', async () => {
|
||
const user = userEvent.setup();
|
||
asShareOwner();
|
||
const writeText = mockClipboard();
|
||
const tokens = ['inv1', 'inv2'];
|
||
server.use(
|
||
http.post('/api/trips/1/invite-link', async () => {
|
||
await delay(20);
|
||
return HttpResponse.json({ token: tokens.shift() });
|
||
}),
|
||
http.delete('/api/trips/1/invite-link', () => HttpResponse.json({ success: true })),
|
||
);
|
||
const view = render(<TripMembersModal {...defaultProps} />);
|
||
|
||
const createBtn = await screen.findByRole('button', { name: /Create invite link/i });
|
||
fireEvent.click(createBtn);
|
||
expect(createBtn).toBeDisabled();
|
||
|
||
await screen.findByDisplayValue(/\/join\/inv1$/);
|
||
|
||
await user.click(screen.getByText('Copy').closest('button')!);
|
||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('/join/inv1'));
|
||
await screen.findByText('Copied');
|
||
// Copying again swaps the pending reset instead of queueing a second one.
|
||
await user.click(screen.getByText('Copied').closest('button')!);
|
||
|
||
const regenerate = screen.getByRole('button', { name: /Regenerate/i });
|
||
fireEvent.click(regenerate);
|
||
expect(regenerate).toBeDisabled();
|
||
expect(screen.getByRole('button', { name: /Disable/i })).toBeDisabled();
|
||
await screen.findByDisplayValue(/\/join\/inv2$/);
|
||
|
||
await user.click(screen.getByRole('button', { name: /Disable/i }));
|
||
await screen.findByRole('button', { name: /Create invite link/i });
|
||
|
||
view.unmount();
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-038: a failing invite-link creation is reported', async () => {
|
||
const user = userEvent.setup();
|
||
asShareOwner();
|
||
server.use(http.post('/api/trips/1/invite-link', () => HttpResponse.json({}, { status: 500 })));
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
|
||
await user.click(await screen.findByRole('button', { name: /Create invite link/i }));
|
||
|
||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Could not create link', 'error', undefined));
|
||
expect(screen.getByRole('button', { name: /Create invite link/i })).toBeEnabled();
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-039: a failing invite-link removal keeps the link', async () => {
|
||
const user = userEvent.setup();
|
||
asShareOwner();
|
||
server.use(
|
||
http.get('/api/trips/1/invite-link', () => HttpResponse.json({ token: 'inv9' })),
|
||
http.delete('/api/trips/1/invite-link', () => HttpResponse.json({}, { status: 500 })),
|
||
);
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
|
||
await user.click(await screen.findByRole('button', { name: /Disable/i }));
|
||
|
||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
|
||
expect(screen.getByDisplayValue(/\/join\/inv9$/)).toBeInTheDocument();
|
||
});
|
||
|
||
// ── Member management errors and ownership transfer (040-045) ─────────────
|
||
|
||
it('FE-COMP-MEMBERS-040: a failing invite surfaces the server error', async () => {
|
||
const user = userEvent.setup();
|
||
server.use(http.post('/api/trips/1/members', () => HttpResponse.json({ error: 'User is already a member' }, { status: 409 })));
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
|
||
await screen.findByText('Invite User');
|
||
await user.click(screen.getByText('Select user…'));
|
||
await user.click(await screen.findByRole('button', { name: 'alice' }));
|
||
await user.click(screen.getByRole('button', { name: 'Invite' }));
|
||
|
||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('User is already a member', 'error', undefined));
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-041: a successful invite reloads the roster and notifies the planner', async () => {
|
||
const user = userEvent.setup();
|
||
const onMembersChanged = vi.fn();
|
||
server.use(http.post('/api/trips/1/members', () => HttpResponse.json({ success: true })));
|
||
render(<TripMembersModal {...defaultProps} onMembersChanged={onMembersChanged} />);
|
||
|
||
await screen.findByText('Invite User');
|
||
await user.click(screen.getByText('Select user…'));
|
||
await user.click(await screen.findByRole('button', { name: 'alice' }));
|
||
await user.click(screen.getByRole('button', { name: 'Invite' }));
|
||
|
||
await waitFor(() => expect(onMembersChanged).toHaveBeenCalled());
|
||
expect(addToast).toHaveBeenCalledWith('alice added', 'success', undefined);
|
||
});
|
||
|
||
// #2478: the pick showed nowhere, only the Invite button knew about it.
|
||
it('FE-COMP-MEMBERS-057: the picked user shows in the field before the invite is sent', async () => {
|
||
const user = userEvent.setup();
|
||
server.use(http.post('/api/trips/1/members', () => HttpResponse.json({ success: true })));
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
|
||
await screen.findByText('Invite User');
|
||
const trigger = screen.getByText('Select user…').closest('button') as HTMLButtonElement;
|
||
await user.click(trigger);
|
||
await user.click(await screen.findByRole('button', { name: 'alice' }));
|
||
|
||
expect(trigger).toHaveTextContent('alice');
|
||
expect(screen.queryByText('Select user…')).toBeNull();
|
||
expect(screen.getByRole('button', { name: 'Invite' })).toBeEnabled();
|
||
|
||
// Once sent, the field is free for the next pick again.
|
||
await user.click(screen.getByRole('button', { name: 'Invite' }));
|
||
await waitFor(() => expect(trigger).toHaveTextContent('Select user…'));
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-042: transferring ownership reloads the app', async () => {
|
||
const onClose = vi.fn();
|
||
const reload = stubReload();
|
||
mockRoster([{ id: memberUser.id, username: 'alice', avatar_url: null }]);
|
||
let transferBody: Record<string, unknown> | null = null;
|
||
server.use(
|
||
http.post('/api/trips/1/transfer', async ({ request }) => {
|
||
transferBody = (await request.json()) as Record<string, unknown>;
|
||
await delay(20);
|
||
return HttpResponse.json({ success: true });
|
||
}),
|
||
);
|
||
render(<TripMembersModal {...defaultProps} onClose={onClose} />);
|
||
|
||
const crown = await screen.findByRole('button', { name: 'Make owner' });
|
||
fireEvent.click(crown);
|
||
fireEvent.click(await screen.findByRole('button', { name: 'Confirm' }));
|
||
expect(crown).toBeDisabled();
|
||
|
||
await waitFor(() => expect(reload).toHaveBeenCalled());
|
||
expect(transferBody).toMatchObject({ newOwnerId: memberUser.id });
|
||
expect(onClose).toHaveBeenCalled();
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-043: declining the transfer confirmation does nothing', async () => {
|
||
const user = userEvent.setup();
|
||
mockRoster([{ id: memberUser.id, username: 'alice', avatar_url: null }]);
|
||
let transferCalled = false;
|
||
server.use(http.post('/api/trips/1/transfer', () => { transferCalled = true; return HttpResponse.json({}); }));
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
|
||
await user.click(await screen.findByRole('button', { name: 'Make owner' }));
|
||
expect(screen.getByText('Transfer ownership to alice? You will become a regular member.')).toBeInTheDocument();
|
||
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
||
|
||
expect(screen.queryByText('Transfer ownership to alice? You will become a regular member.')).not.toBeInTheDocument();
|
||
expect(transferCalled).toBe(false);
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-044: a failing transfer re-enables the button', async () => {
|
||
const user = userEvent.setup();
|
||
mockRoster([{ id: memberUser.id, username: 'alice', avatar_url: null }]);
|
||
server.use(http.post('/api/trips/1/transfer', () => HttpResponse.json({ error: 'Not allowed' }, { status: 403 })));
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
|
||
await user.click(await screen.findByRole('button', { name: 'Make owner' }));
|
||
await user.click(screen.getByRole('button', { name: 'Confirm' }));
|
||
|
||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Not allowed', 'error', undefined));
|
||
expect(screen.getByRole('button', { name: 'Make owner' })).toBeEnabled();
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-045: member removal is cancellable and reports failures', async () => {
|
||
const user = userEvent.setup();
|
||
mockRoster([{ id: memberUser.id, username: 'alice', avatar_url: null }]);
|
||
let deletes = 0;
|
||
server.use(http.delete('/api/trips/1/members/:userId', () => { deletes++; return HttpResponse.json({}, { status: 500 }); }));
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
|
||
await user.click(await screen.findByRole('button', { name: 'Remove access' }));
|
||
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
||
expect(deletes).toBe(0);
|
||
|
||
await user.click(screen.getByRole('button', { name: 'Remove access' }));
|
||
await user.click(screen.getByRole('button', { name: 'Confirm' }));
|
||
|
||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to remove', 'error', undefined));
|
||
expect(screen.getByText('alice')).toBeInTheDocument();
|
||
});
|
||
|
||
// ── Guests (046-051) ──────────────────────────────────────────────────────
|
||
|
||
const guestRow = { id: 3, username: 'Grandma', avatar_url: null, is_guest: true };
|
||
|
||
it('FE-COMP-MEMBERS-046: pressing Enter adds a guest, an empty name is ignored', async () => {
|
||
const user = userEvent.setup();
|
||
const onMembersChanged = vi.fn();
|
||
let createdName: string | null = null;
|
||
server.use(
|
||
http.post('/api/trips/1/guests', async ({ request }) => {
|
||
createdName = ((await request.json()) as { name: string }).name;
|
||
return HttpResponse.json({ member: { id: 9, username: createdName, is_guest: true } });
|
||
}),
|
||
);
|
||
render(<TripMembersModal {...defaultProps} onMembersChanged={onMembersChanged} />);
|
||
|
||
const input = await screen.findByPlaceholderText('Guest name');
|
||
fireEvent.keyDown(input, { key: 'Enter' });
|
||
expect(createdName).toBeNull();
|
||
|
||
await user.type(input, 'Grandpa');
|
||
fireEvent.keyDown(input, { key: 'Enter' });
|
||
|
||
await waitFor(() => expect(createdName).toBe('Grandpa'));
|
||
expect(addToast).toHaveBeenCalledWith('Guest added', 'success', undefined);
|
||
await waitFor(() => expect(onMembersChanged).toHaveBeenCalled());
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-047: a failing guest creation is reported', async () => {
|
||
const user = userEvent.setup();
|
||
server.use(http.post('/api/trips/1/guests', () => HttpResponse.json({ error: 'Guest limit reached' }, { status: 400 })));
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
|
||
await user.type(await screen.findByPlaceholderText('Guest name'), 'Grandpa');
|
||
await user.click(screen.getByRole('button', { name: /Add guest/i }));
|
||
|
||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Guest limit reached', 'error', undefined));
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-048: renaming a guest saves on Enter', async () => {
|
||
const user = userEvent.setup();
|
||
mockRoster([guestRow]);
|
||
let renamed: { id: string; name: string } | null = null;
|
||
server.use(
|
||
http.put('/api/trips/1/guests/:userId', async ({ params, request }) => {
|
||
renamed = { id: params.userId as string, name: ((await request.json()) as { name: string }).name };
|
||
return HttpResponse.json({ success: true });
|
||
}),
|
||
);
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
|
||
await user.click(await screen.findByRole('button', { name: 'Rename' }));
|
||
|
||
const input = screen.getByDisplayValue('Grandma');
|
||
fireEvent.change(input, { target: { value: 'Granny' } });
|
||
fireEvent.keyDown(input, { key: 'Enter' });
|
||
|
||
await waitFor(() => expect(renamed).toEqual({ id: '3', name: 'Granny' }));
|
||
await waitFor(() => expect(screen.queryByDisplayValue('Granny')).not.toBeInTheDocument());
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-048b: Enter followed by a blur renames only once', async () => {
|
||
const user = userEvent.setup();
|
||
mockRoster([guestRow]);
|
||
let renames = 0;
|
||
server.use(http.put('/api/trips/1/guests/:userId', () => { renames++; return HttpResponse.json({ success: true }); }));
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
|
||
await user.click(await screen.findByRole('button', { name: 'Rename' }));
|
||
const input = screen.getByDisplayValue('Grandma');
|
||
fireEvent.change(input, { target: { value: 'Granny' } });
|
||
fireEvent.keyDown(input, { key: 'Enter' });
|
||
fireEvent.blur(input);
|
||
|
||
await waitFor(() => expect(screen.queryByDisplayValue('Granny')).not.toBeInTheDocument());
|
||
expect(renames).toBe(1);
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-049: Escape cancels a rename and a blank name saves nothing', async () => {
|
||
const user = userEvent.setup();
|
||
mockRoster([guestRow]);
|
||
let renames = 0;
|
||
server.use(http.put('/api/trips/1/guests/:userId', () => { renames++; return HttpResponse.json({ success: true }); }));
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
|
||
await user.click(await screen.findByRole('button', { name: 'Rename' }));
|
||
fireEvent.keyDown(screen.getByDisplayValue('Grandma'), { key: 'Escape' });
|
||
expect(screen.queryByDisplayValue('Grandma')).not.toBeInTheDocument();
|
||
|
||
await user.click(screen.getByRole('button', { name: 'Rename' }));
|
||
const input = screen.getByDisplayValue('Grandma');
|
||
fireEvent.change(input, { target: { value: ' ' } });
|
||
fireEvent.blur(input);
|
||
|
||
await waitFor(() => expect(screen.queryByDisplayValue(' ')).not.toBeInTheDocument());
|
||
expect(renames).toBe(0);
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-050: a failing rename is reported', async () => {
|
||
const user = userEvent.setup();
|
||
mockRoster([guestRow]);
|
||
server.use(http.put('/api/trips/1/guests/:userId', () => HttpResponse.json({ error: 'Name taken' }, { status: 409 })));
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
|
||
await user.click(await screen.findByRole('button', { name: 'Rename' }));
|
||
const input = screen.getByDisplayValue('Grandma');
|
||
fireEvent.change(input, { target: { value: 'Granny' } });
|
||
fireEvent.keyDown(input, { key: 'Enter' });
|
||
|
||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Name taken', 'error', undefined));
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-051: removing a guest is confirmed, refreshes costs and notifies', async () => {
|
||
const user = userEvent.setup();
|
||
const onMembersChanged = vi.fn();
|
||
mockRoster([guestRow]);
|
||
let deletedId: string | null = null;
|
||
server.use(
|
||
http.delete('/api/trips/1/guests/:userId', async ({ params }) => {
|
||
deletedId = params.userId as string;
|
||
await delay(20);
|
||
return HttpResponse.json({ success: true });
|
||
}),
|
||
);
|
||
render(<TripMembersModal {...defaultProps} onMembersChanged={onMembersChanged} />);
|
||
|
||
const trash = await screen.findByRole('button', { name: 'Remove access' });
|
||
await user.click(trash);
|
||
expect(screen.getByText('Remove this guest? Their assignments and cost shares will be removed too.')).toBeInTheDocument();
|
||
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
||
expect(deletedId).toBeNull();
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: 'Remove access' }));
|
||
fireEvent.click(await screen.findByRole('button', { name: 'Delete' }));
|
||
await waitFor(() => expect(screen.getByRole('button', { name: 'Remove access' })).toBeDisabled());
|
||
|
||
await waitFor(() => expect(deletedId).toBe('3'));
|
||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Guest removed', 'success', undefined));
|
||
expect(onMembersChanged).toHaveBeenCalled();
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-052: a failing guest removal is reported', async () => {
|
||
const user = userEvent.setup();
|
||
mockRoster([guestRow]);
|
||
server.use(http.delete('/api/trips/1/guests/:userId', () => HttpResponse.json({}, { status: 500 })));
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
|
||
await user.click(await screen.findByRole('button', { name: 'Remove access' }));
|
||
await user.click(screen.getByRole('button', { name: 'Delete' }));
|
||
|
||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to remove', 'error', undefined));
|
||
expect(screen.getByText('Grandma')).toBeInTheDocument();
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-053: without member_manage a plain member sees no invite or guest controls', async () => {
|
||
const stranger = buildUser({ id: 99, username: 'stranger' });
|
||
seedStore(useAuthStore, { user: stranger, isAuthenticated: true });
|
||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) });
|
||
seedStore(usePermissionsStore, { permissions: { member_manage: 'trip_owner', share_manage: 'trip_owner' } });
|
||
mockRoster([{ id: memberUser.id, username: 'alice', avatar_url: null }]);
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
|
||
await screen.findByText('alice');
|
||
expect(screen.queryByText('Invite User')).not.toBeInTheDocument();
|
||
expect(screen.queryByPlaceholderText('Guest name')).not.toBeInTheDocument();
|
||
expect(screen.queryByText('Guests')).not.toBeInTheDocument();
|
||
expect(screen.queryByText('Public Link')).not.toBeInTheDocument();
|
||
// Nothing to remove either — a plain member can only leave themselves.
|
||
expect(screen.queryByRole('button', { name: 'Remove access' })).not.toBeInTheDocument();
|
||
});
|
||
|
||
// A self-hosted install served over plain HTTP has no navigator.clipboard, and
|
||
// the unguarded call threw before the button ever showed feedback.
|
||
it('FE-COMP-MEMBERS-055: the share link copies through execCommand without a clipboard API', async () => {
|
||
const user = userEvent.setup();
|
||
asShareOwner();
|
||
Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true, writable: true });
|
||
Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true, writable: true });
|
||
const execCommand = vi.fn(() => true);
|
||
Object.defineProperty(document, 'execCommand', { value: execCommand, configurable: true, writable: true });
|
||
server.use(http.get('/api/trips/1/share-link', () => HttpResponse.json({ token: 'tok55' })));
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
|
||
await user.click((await screen.findByText('Copy')).closest('button')!);
|
||
|
||
expect(execCommand).toHaveBeenCalledWith('copy');
|
||
await screen.findByText('Copied');
|
||
expect(document.querySelector('textarea')).toBeNull();
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-056: the invite link copies through execCommand without a clipboard API', async () => {
|
||
const user = userEvent.setup();
|
||
asShareOwner();
|
||
Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true, writable: true });
|
||
Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true, writable: true });
|
||
const execCommand = vi.fn(() => true);
|
||
Object.defineProperty(document, 'execCommand', { value: execCommand, configurable: true, writable: true });
|
||
server.use(http.get('/api/trips/1/invite-link', () => HttpResponse.json({ token: 'inv56' })));
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
|
||
await user.click((await screen.findByText('Copy')).closest('button')!);
|
||
|
||
expect(execCommand).toHaveBeenCalledWith('copy');
|
||
await screen.findByText('Copied');
|
||
expect(document.querySelector('textarea')).toBeNull();
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-054: the invite-link copied badge resets after two seconds', async () => {
|
||
asShareOwner();
|
||
const writeText = mockClipboard();
|
||
server.use(http.get('/api/trips/1/invite-link', () => HttpResponse.json({ token: 'inv9' })));
|
||
const view = render(<TripMembersModal {...defaultProps} />);
|
||
|
||
const copyBtn = (await screen.findByText('Copy')).closest('button')!;
|
||
|
||
vi.useFakeTimers();
|
||
fireEvent.click(copyBtn);
|
||
await act(async () => {});
|
||
expect(screen.getByText('Copied')).toBeInTheDocument();
|
||
act(() => { vi.advanceTimersByTime(2000); });
|
||
|
||
expect(screen.getByText('Copy')).toBeInTheDocument();
|
||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('/join/inv9'));
|
||
view.unmount();
|
||
});
|
||
});
|
||
|
||
describe('TripMembersModal on a phone', () => {
|
||
let width: number;
|
||
beforeEach(() => {
|
||
width = window.innerWidth;
|
||
Object.defineProperty(window, 'innerWidth', { value: 375, configurable: true, writable: true });
|
||
});
|
||
afterEach(() => {
|
||
Object.defineProperty(window, 'innerWidth', { value: width, configurable: true, writable: true });
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-058b: the public link toggles say whether they are on (#2604)', async () => {
|
||
seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } });
|
||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) });
|
||
server.use(http.get('/api/trips/1/share-link', () => HttpResponse.json({
|
||
token: 'abc123', share_map: true, share_bookings: true, share_packing: false, share_budget: false, share_collab: false,
|
||
})));
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
|
||
expect(await screen.findByRole('button', { name: 'Bookings' })).toHaveAttribute('aria-pressed', 'true');
|
||
expect(screen.getByRole('button', { name: 'Costs' })).toHaveAttribute('aria-pressed', 'false');
|
||
expect(screen.getByRole('button', { name: 'Map & Plan' })).toHaveAttribute('aria-disabled', 'true');
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-058: keeps the sheet it had, with "(you)" beside the name', async () => {
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
expect(await screen.findByText(/\(you\)/i)).toBeInTheDocument();
|
||
expect(screen.getByText(/Access \(1/)).toBeInTheDocument();
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-059: asks with the browser before a member goes, and only then removes', async () => {
|
||
const user = userEvent.setup();
|
||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||
mockRoster([{ id: memberUser.id, username: 'alice', avatar_url: null }]);
|
||
let deletes = 0;
|
||
server.use(http.delete('/api/trips/1/members/:userId', () => { deletes++; return HttpResponse.json({ success: true }); }));
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
|
||
await user.click(await screen.findByTitle('Remove access'));
|
||
expect(confirmSpy).toHaveBeenCalledWith('Remove access for this user?');
|
||
expect(deletes).toBe(0);
|
||
|
||
confirmSpy.mockReturnValue(true);
|
||
await user.click(screen.getByTitle('Remove access'));
|
||
await waitFor(() => expect(deletes).toBe(1));
|
||
});
|
||
|
||
it('FE-COMP-MEMBERS-060: a guest is renamed and removed through the same questions', async () => {
|
||
const user = userEvent.setup();
|
||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||
mockRoster([{ id: 3, username: 'Grandma', avatar_url: null, is_guest: true }]);
|
||
let renamed: string | null = null;
|
||
let deleted = false;
|
||
server.use(
|
||
http.put('/api/trips/1/guests/:userId', async ({ request }) => {
|
||
renamed = ((await request.json()) as { name: string }).name;
|
||
return HttpResponse.json({ success: true });
|
||
}),
|
||
http.delete('/api/trips/1/guests/:userId', () => { deleted = true; return HttpResponse.json({ success: true }); }),
|
||
);
|
||
render(<TripMembersModal {...defaultProps} />);
|
||
|
||
await user.click(await screen.findByTitle('Rename'));
|
||
const input = screen.getByDisplayValue('Grandma');
|
||
fireEvent.keyDown(input, { key: 'Escape' });
|
||
await user.click(screen.getByTitle('Rename'));
|
||
fireEvent.change(screen.getByDisplayValue('Grandma'), { target: { value: 'Granny' } });
|
||
fireEvent.keyDown(screen.getByDisplayValue('Granny'), { key: 'Enter' });
|
||
await waitFor(() => expect(renamed).toBe('Granny'));
|
||
|
||
await user.click(screen.getByTitle('Remove access'));
|
||
await waitFor(() => expect(deleted).toBe(true));
|
||
});
|
||
});
|