test: expand frontend test suite to 82% coverage

Adds ~45 new and updated test files covering Admin, Collab, Dashboard, Map, Memories, PDF, Photos, Planner, Settings, Vacay, Weather components, pages, stores, and a WebSocket integration test.
This commit is contained in:
jubnl
2026-04-08 21:14:23 +02:00
parent 2b7057b922
commit d4bb8be86b
45 changed files with 13643 additions and 524 deletions
@@ -1,10 +1,12 @@
// FE-COMP-TRIPFORM-001 to FE-COMP-TRIPFORM-015
import { render, screen, waitFor } from '../../../tests/helpers/render';
// FE-COMP-TRIPFORM-001 to FE-COMP-TRIPFORM-028
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
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 { server } from '../../../tests/helpers/msw/server';
import TripFormModal from './TripFormModal';
const defaultProps = {
@@ -129,4 +131,159 @@ describe('TripFormModal', () => {
expect(screen.getByText('Start Date')).toBeInTheDocument();
expect(screen.getByText('End Date')).toBeInTheDocument();
});
it('FE-COMP-TRIPFORM-016: end-date validation shows error when end < start', async () => {
const user = userEvent.setup();
const onSave = vi.fn();
// Trip with end_date before start_date; title is set so title validation passes
const trip = buildTrip({ id: 1, title: 'Test Trip', start_date: '2026-06-15', end_date: '2026-06-01' } as any);
render(<TripFormModal {...defaultProps} trip={trip} onSave={onSave} />);
const updateBtn = screen.getByRole('button', { name: /Update/i });
await user.click(updateBtn);
await screen.findByText('End date must be after start date');
expect(onSave).not.toHaveBeenCalled();
});
it('FE-COMP-TRIPFORM-017: day count field visible when no dates set', () => {
render(<TripFormModal {...defaultProps} trip={null} />);
expect(screen.getByText('Number of Days')).toBeInTheDocument();
});
it('FE-COMP-TRIPFORM-018: day count hidden when trip has dates', () => {
const trip = buildTrip({ id: 1, start_date: '2026-06-01', end_date: '2026-06-10' });
render(<TripFormModal {...defaultProps} trip={trip} />);
expect(screen.queryByText('Number of Days')).not.toBeInTheDocument();
});
it('FE-COMP-TRIPFORM-019: reminder buttons visible when tripRemindersEnabled=true', async () => {
seedStore(useAuthStore, { tripRemindersEnabled: true });
render(<TripFormModal {...defaultProps} trip={null} />);
expect(screen.getByRole('button', { name: 'None' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '1 day' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '3 days' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '9 days' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Custom' })).toBeInTheDocument();
});
it('FE-COMP-TRIPFORM-020: reminder section shows disabled hint when tripRemindersEnabled=false', () => {
seedStore(useAuthStore, { tripRemindersEnabled: false });
render(<TripFormModal {...defaultProps} trip={null} />);
expect(screen.getByText(/Trip reminders are disabled/i)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'None' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Custom' })).not.toBeInTheDocument();
});
it('FE-COMP-TRIPFORM-021: custom reminder input appears and accepts value', async () => {
const user = userEvent.setup();
seedStore(useAuthStore, { tripRemindersEnabled: true });
render(<TripFormModal {...defaultProps} trip={null} />);
await user.click(screen.getByRole('button', { name: 'Custom' }));
// custom reminder input has max=30
const customInput = document.querySelector('input[max="30"]') as HTMLInputElement;
expect(customInput).toBeInTheDocument();
// Use fireEvent.change to set the value directly (avoids clamping from char-by-char typing)
fireEvent.change(customInput, { target: { value: '14' } });
expect(customInput.value).toBe('14');
});
it('FE-COMP-TRIPFORM-022: member selector not visible when editing existing trip', () => {
const trip = buildTrip({ id: 1 });
render(<TripFormModal {...defaultProps} trip={trip} />);
expect(screen.queryByText('Travel buddies')).not.toBeInTheDocument();
});
it('FE-COMP-TRIPFORM-023: member selector appears when creating and other users exist', async () => {
server.use(
http.get('/api/auth/users', () =>
HttpResponse.json({ users: [{ id: 100, username: 'alice' }] })
)
);
render(<TripFormModal {...defaultProps} trip={null} />);
await screen.findByText('Travel buddies');
});
it('FE-COMP-TRIPFORM-024: selecting a member adds a chip', async () => {
const user = userEvent.setup();
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'me' }), isAuthenticated: true });
server.use(
http.get('/api/auth/users', () =>
HttpResponse.json({ users: [{ id: 100, username: 'alice' }] })
)
);
render(<TripFormModal {...defaultProps} trip={null} />);
// Wait for member section to load
await screen.findByText('Travel buddies');
// Click the CustomSelect trigger (placeholder "Add member")
const selectTrigger = screen.getByText('Add member').closest('button')!;
await user.click(selectTrigger);
// alice option appears in portal (document.body)
const aliceOption = await screen.findByRole('button', { name: 'alice' });
await user.click(aliceOption);
// alice chip should now be in the member chip list
expect(screen.getByText('alice')).toBeInTheDocument();
});
it('FE-COMP-TRIPFORM-025: removing a member chip deselects them', async () => {
const user = userEvent.setup();
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'me' }), isAuthenticated: true });
server.use(
http.get('/api/auth/users', () =>
HttpResponse.json({ users: [{ id: 100, username: 'alice' }] })
)
);
render(<TripFormModal {...defaultProps} trip={null} />);
await screen.findByText('Travel buddies');
// Select alice
const selectTrigger = screen.getByText('Add member').closest('button')!;
await user.click(selectTrigger);
const aliceOption = await screen.findByRole('button', { name: 'alice' });
await user.click(aliceOption);
// alice chip is present
const aliceChip = screen.getByText('alice');
expect(aliceChip).toBeInTheDocument();
// Click the chip to remove alice
await user.click(aliceChip.closest('span')!);
// alice chip should be gone
await waitFor(() => expect(screen.queryByText('alice')).not.toBeInTheDocument());
});
it('FE-COMP-TRIPFORM-026: cover image paste fires URL.createObjectURL', async () => {
const mockCreateObjectURL = vi.fn(() => 'blob:mock-paste-url');
const original = URL.createObjectURL;
Object.defineProperty(URL, 'createObjectURL', { writable: true, configurable: true, value: mockCreateObjectURL });
render(<TripFormModal {...defaultProps} trip={null} />);
const form = document.querySelector('form')!;
const file = new File(['img'], 'cover.png', { type: 'image/png' });
fireEvent.paste(form, {
clipboardData: {
items: [{ type: 'image/png', getAsFile: () => file }],
},
});
expect(mockCreateObjectURL).toHaveBeenCalledWith(file);
Object.defineProperty(URL, 'createObjectURL', { writable: true, configurable: true, value: original });
});
it('FE-COMP-TRIPFORM-027: onSave error message is displayed', async () => {
const user = userEvent.setup();
const onSave = vi.fn().mockRejectedValue(new Error('Server error'));
render(<TripFormModal {...defaultProps} onSave={onSave} trip={null} />);
await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'My Trip');
const submitBtns = screen.getAllByText('Create New Trip');
const submitBtn = submitBtns.find(el => el.closest('button'))!;
await user.click(submitBtn.closest('button')!);
await screen.findByText('Server error');
});
it('FE-COMP-TRIPFORM-028: loading spinner shown while submitting', async () => {
const user = userEvent.setup();
const onSave = vi.fn().mockImplementation(() => new Promise(() => {}));
render(<TripFormModal {...defaultProps} onSave={onSave} trip={null} />);
await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'My Trip');
const submitBtns = screen.getAllByText('Create New Trip');
const submitBtn = submitBtns.find(el => el.closest('button'))!;
await user.click(submitBtn.closest('button')!);
await waitFor(() => expect(screen.getByText('Saving...')).toBeInTheDocument());
});
});
@@ -1,10 +1,11 @@
// FE-COMP-MEMBERS-001 to FE-COMP-MEMBERS-015
// FE-COMP-MEMBERS-001 to FE-COMP-MEMBERS-025
import { render, screen, waitFor } 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 { usePermissionsStore } from '../../store/permissionsStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip } from '../../../tests/helpers/factories';
import TripMembersModal from './TripMembersModal';
@@ -172,4 +173,254 @@ describe('TripMembersModal', () => {
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, owner_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, owner_id: ownerUser.id }) });
render(<TripMembersModal {...defaultProps} />);
await screen.findByText('Public Link');
});
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, owner_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();
});
});
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, owner_id: ownerUser.id }) });
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
value: { writeText },
configurable: true,
});
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, owner_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, owner_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 Invite button
const inviteBtn = screen.getByRole('button', { name: /Invite/i });
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();
vi.spyOn(window, 'confirm').mockReturnValue(true);
Object.defineProperty(window, 'location', {
value: { ...window.location, reload: vi.fn() },
writable: true,
configurable: true,
});
seedStore(useAuthStore, { user: memberUser, isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_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');
const leaveBtn = screen.getByTitle('Leave trip');
await user.click(leaveBtn);
await waitFor(() => {
expect(deleteCalledForUserId).toBe(String(memberUser.id));
});
vi.restoreAllMocks();
});
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} />);
await screen.findByText('All users already have access.');
});
});