mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
b194e8317d
Add genuine offline read/write capability for trips: - Dexie IndexedDB schema (trips, places, packing, todo, budget, reservations, files, mutationQueue, syncMeta, blobCache) - Repo layer for all domains: offline reads from Dexie, writes optimistically to Dexie and enqueue mutations for later replay - Mutation queue with UUID idempotency keys (X-Idempotency-Key), FIFO flush, temp-ID reconciliation on 2xx, fail-and-continue on 4xx - Trip sync manager: caches all trips with end_date >= today or null, auto-evicts 7d after end_date, fetches bundle endpoint in one request - Map tile prefetcher: bbox from place coords, zooms 10-16, 50MB cap, warms SW cache via fetch - Sync triggers: network online → flush + syncAll; WS reconnect → flush only (rate-limiter safe); visibilitychange/30s → flush only - WS remoteEventHandler writes through to Dexie on every event - Server idempotency middleware + idempotency_keys table (migration 100, 24h TTL nightly cleanup) - GET /api/trips/:id/bundle endpoint for efficient single-request sync - OfflineBanner component: amber (offline) / blue (syncing) / hidden - OfflineTab in Settings: cached trip list, re-sync and clear actions - usePendingMutations hook for per-item pending indicators Closes #505 #541
276 lines
9.7 KiB
TypeScript
276 lines
9.7 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 } 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 { 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: [] },
|
|
],
|
|
multi_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, owner_id: 1 }) });
|
|
});
|
|
|
|
describe('CollabPolls', () => {
|
|
it('FE-COMP-POLLS-001: renders empty state when no polls exist', async () => {
|
|
render(<CollabPolls {...defaultProps} />);
|
|
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
|
});
|
|
|
|
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} />);
|
|
await screen.findByText('Best destination?');
|
|
});
|
|
|
|
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
|
|
await screen.findByPlaceholderText(/what should we do/i);
|
|
});
|
|
|
|
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 }));
|
|
await screen.findByText('Where to eat?');
|
|
});
|
|
|
|
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} />);
|
|
await screen.findByText(/closed/i);
|
|
});
|
|
|
|
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.getByTitle(/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({ type: 'collab:poll:created', poll: buildPoll({ id: 77, question: 'Live poll?' }) });
|
|
|
|
await screen.findByText('Live poll?');
|
|
});
|
|
|
|
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({ 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);
|
|
});
|
|
});
|