test: add comprehensive coverage for OAuth scopes, MCP, and core services

Adds new and expanded test suites across client and server to cover the
OAuth 2.1 scope system, MCP session manager, collab service, unified
memories helpers, OIDC service, budget slice, and OAuth authorize page.
Also extends SonarQube coverage exclusions to include bootstrapping files
(migrations, scheduler, main.tsx, types.ts) that are not meaningfully
testable.
This commit is contained in:
jubnl
2026-04-11 14:07:56 +02:00
parent 1585c472c2
commit 7a22d742ab
19 changed files with 2676 additions and 10 deletions
+102
View File
@@ -0,0 +1,102 @@
// FE-OAUTH-SCOPES-001 to FE-OAUTH-SCOPES-010
import { describe, it, expect } from 'vitest'
import { SCOPE_GROUPS, ALL_SCOPES, SCOPE_GROUP_NAMES, getScopesByGroup } from './oauthScopes'
describe('SCOPE_GROUPS', () => {
it('FE-OAUTH-SCOPES-001: contains all expected scope keys', () => {
const expected = [
'trips:read', 'trips:write', 'trips:delete', 'trips:share',
'places:read', 'places:write',
'atlas:read', 'atlas:write',
'packing:read', 'packing:write',
'todos:read', 'todos:write',
'budget:read', 'budget:write',
'reservations:read', 'reservations:write',
'collab:read', 'collab:write',
'notifications:read', 'notifications:write',
'vacay:read', 'vacay:write',
'geo:read', 'weather:read',
]
for (const scope of expected) {
expect(SCOPE_GROUPS).toHaveProperty(scope)
}
})
it('FE-OAUTH-SCOPES-002: each scope entry has labelKey, descriptionKey, groupKey', () => {
for (const [scope, keys] of Object.entries(SCOPE_GROUPS)) {
expect(keys.labelKey, `${scope} missing labelKey`).toBeTruthy()
expect(keys.descriptionKey, `${scope} missing descriptionKey`).toBeTruthy()
expect(keys.groupKey, `${scope} missing groupKey`).toBeTruthy()
}
})
})
describe('ALL_SCOPES', () => {
it('FE-OAUTH-SCOPES-003: contains exactly 24 scopes', () => {
expect(ALL_SCOPES).toHaveLength(24)
})
it('FE-OAUTH-SCOPES-004: matches Object.keys(SCOPE_GROUPS)', () => {
expect(ALL_SCOPES).toEqual(Object.keys(SCOPE_GROUPS))
})
})
describe('SCOPE_GROUP_NAMES', () => {
it('FE-OAUTH-SCOPES-005: contains no duplicate group names', () => {
expect(SCOPE_GROUP_NAMES).toHaveLength(new Set(SCOPE_GROUP_NAMES).size)
})
it('FE-OAUTH-SCOPES-006: contains expected groups', () => {
const expected = [
'oauth.scope.group.trips',
'oauth.scope.group.places',
'oauth.scope.group.packing',
'oauth.scope.group.budget',
]
for (const g of expected) {
expect(SCOPE_GROUP_NAMES).toContain(g)
}
})
})
describe('getScopesByGroup', () => {
const identity = (key: string) => key
it('FE-OAUTH-SCOPES-007: groups all scopes under the correct group key', () => {
const groups = getScopesByGroup(identity)
// Every scope must appear exactly once across all groups
const allScopesInGroups = Object.values(groups).flat().map(s => s.scope)
expect(allScopesInGroups).toHaveLength(ALL_SCOPES.length)
for (const scope of ALL_SCOPES) {
expect(allScopesInGroups).toContain(scope)
}
})
it('FE-OAUTH-SCOPES-008: each item has scope, label, description, group', () => {
const groups = getScopesByGroup(identity)
for (const items of Object.values(groups)) {
for (const item of items) {
expect(item.scope).toBeTruthy()
expect(item.label).toBeTruthy()
expect(item.description).toBeTruthy()
expect(item.group).toBeTruthy()
}
}
})
it('FE-OAUTH-SCOPES-009: trips group contains trips:read and trips:write', () => {
const groups = getScopesByGroup(identity)
const tripsGroup = groups['oauth.scope.group.trips']
expect(tripsGroup).toBeDefined()
const scopeNames = tripsGroup.map(s => s.scope)
expect(scopeNames).toContain('trips:read')
expect(scopeNames).toContain('trips:write')
})
it('FE-OAUTH-SCOPES-010: uses translated group name as key', () => {
const t = (key: string) => key === 'oauth.scope.group.trips' ? 'Trips' : key
const groups = getScopesByGroup(t)
expect(groups['Trips']).toBeDefined()
expect(groups['oauth.scope.group.trips']).toBeUndefined()
})
})
@@ -1,4 +1,4 @@
// FE-ADMIN-MCP-001 to FE-ADMIN-MCP-010
// FE-ADMIN-MCP-001 to FE-ADMIN-MCP-016
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
@@ -197,4 +197,127 @@ describe('AdminMcpTokensPanel', () => {
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
await screen.findByText('Failed to load tokens');
});
it('FE-ADMIN-MCP-011: OAuth sessions loading spinner shown on mount', async () => {
server.use(
http.get('/api/admin/oauth-sessions', async () => {
await new Promise(resolve => setTimeout(resolve, 200));
return HttpResponse.json({ sessions: [] });
})
);
render(<AdminMcpTokensPanel />);
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
});
it('FE-ADMIN-MCP-012: OAuth sessions empty state rendered when no sessions', async () => {
server.use(
http.get('/api/admin/oauth-sessions', () =>
HttpResponse.json({ sessions: [] })
)
);
render(<AdminMcpTokensPanel />);
await screen.findByText('No active OAuth sessions');
});
it('FE-ADMIN-MCP-013: OAuth sessions list renders with scopes', async () => {
server.use(
http.get('/api/admin/oauth-sessions', () =>
HttpResponse.json({
sessions: [
{
id: 1,
client_name: 'Claude Desktop',
username: 'alice',
scopes: ['trips:read', 'budget:read'],
created_at: '2025-01-01T00:00:00Z',
},
],
})
)
);
render(<AdminMcpTokensPanel />);
await screen.findByText('Claude Desktop');
expect(screen.getByText('alice')).toBeInTheDocument();
expect(screen.getByText('trips:read')).toBeInTheDocument();
});
it('FE-ADMIN-MCP-014: scope expand/collapse toggle shows hidden scopes', async () => {
const user = userEvent.setup();
// 7 scopes — more than SCOPES_PREVIEW=6, so "+1 more" button appears
const scopes = ['trips:read', 'trips:write', 'places:read', 'places:write', 'budget:read', 'budget:write', 'packing:read'];
server.use(
http.get('/api/admin/oauth-sessions', () =>
HttpResponse.json({
sessions: [
{ id: 1, client_name: 'App', username: 'bob', scopes, created_at: '2025-01-01T00:00:00Z' },
],
})
)
);
render(<AdminMcpTokensPanel />);
await screen.findByText('App');
// "+1 more" button should appear
const moreBtn = await screen.findByText(/\+1 more/);
expect(moreBtn).toBeInTheDocument();
await user.click(moreBtn);
// After expand, "show less" appears
expect(await screen.findByText('show less')).toBeInTheDocument();
});
it('FE-ADMIN-MCP-015: revoke session confirmation and successful revoke', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/oauth-sessions', () =>
HttpResponse.json({
sessions: [
{ id: 5, client_name: 'Revoke Me', username: 'carol', scopes: ['trips:read'], created_at: '2025-01-01T00:00:00Z' },
],
})
),
http.delete('/api/admin/oauth-sessions/5', () =>
HttpResponse.json({ success: true })
)
);
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
await screen.findByText('Revoke Me');
// Click the revoke (trash) button next to the session
const deleteBtn = screen.getAllByTitle('Delete')[0];
await user.click(deleteBtn);
// Confirmation modal opens
expect(screen.getByText('Revoke Session')).toBeInTheDocument();
// Confirm — find the modal's Delete button (has no title, unlike the trash icon)
const deleteBtns = screen.getAllByRole('button', { name: 'Delete' });
const confirmBtn = deleteBtns.find(b => !b.title);
await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]);
await waitFor(() => {
expect(screen.queryByText('Revoke Me')).not.toBeInTheDocument();
});
});
it('FE-ADMIN-MCP-016: revoke session error shows toast', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/oauth-sessions', () =>
HttpResponse.json({
sessions: [
{ id: 6, client_name: 'Error Session', username: 'dave', scopes: ['trips:read'], created_at: '2025-01-01T00:00:00Z' },
],
})
),
http.delete('/api/admin/oauth-sessions/6', () =>
HttpResponse.json({ error: 'forbidden' }, { status: 403 })
)
);
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
await screen.findByText('Error Session');
const deleteBtn = screen.getAllByTitle('Delete')[0];
await user.click(deleteBtn);
const deleteBtns = screen.getAllByRole('button', { name: 'Delete' });
const confirmBtn = deleteBtns.find(b => !b.title);
await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]);
await screen.findByText('Failed to revoke session');
});
});
@@ -1,4 +1,4 @@
// FE-COMP-BUDGET-001 to FE-COMP-BUDGET-020
// FE-COMP-BUDGET-001 to FE-COMP-BUDGET-040
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
@@ -6,6 +6,7 @@ import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import { useSettingsStore } from '../../store/settingsStore';
import { usePermissionsStore } from '../../store/permissionsStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip, buildBudgetItem, buildSettings } from '../../../tests/helpers/factories';
import BudgetPanel from './BudgetPanel';
@@ -418,4 +419,80 @@ describe('BudgetPanel', () => {
// Grand total card shows 300.00
expect(screen.getByText('300.00')).toBeInTheDocument();
});
it('FE-COMP-BUDGET-033: read-only mode hides add/delete/edit controls', async () => {
// Restrict budget_edit to trip owners only; user is not the owner (owner_id=1, user.id > 1)
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
// Use a user with id != 1 so they're not the owner
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Read Only Item' }), total_price: 50 };
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('Read Only Item');
// In read-only mode the Delete button should not be visible
expect(screen.queryByTitle('Delete')).not.toBeInTheDocument();
});
it('FE-COMP-BUDGET-034: read-only mode shows expense_date as text span', async () => {
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Train' }), total_price: 30, expense_date: '2025-06-15' };
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('Train');
// expense_date is rendered as plain text in read-only mode
await screen.findByText('2025-06-15');
});
it('FE-COMP-BUDGET-035: settlement section with avatar renders user avatar image', async () => {
const user = userEvent.setup();
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Lunch' }), total_price: 60 };
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
http.get('/api/trips/1/budget/settlement', () =>
HttpResponse.json({
balances: [
{ user_id: 1, username: 'alice', avatar_url: '/uploads/avatars/alice.jpg', balance: -30 },
{ user_id: 2, username: 'bob', avatar_url: null, balance: 30 },
],
flows: [{ from: { username: 'alice', avatar_url: '/uploads/avatars/alice.jpg' }, to: { username: 'bob', avatar_url: null }, amount: 30 }]
})
),
http.get('/api/trips/1/budget/per-person', () => HttpResponse.json({ summary: [] })),
);
const tripMembers = [
{ id: 1, username: 'alice', avatar_url: '/uploads/avatars/alice.jpg' },
{ id: 2, username: 'bob', avatar_url: null },
];
render(<BudgetPanel tripId={1} tripMembers={tripMembers} />);
await screen.findByText('Lunch');
// Trigger settlement display
const settlementBtn = await screen.findByRole('button', { name: /settlement/i });
await user.click(settlementBtn);
await screen.findByText('alice');
// Avatar image should be rendered for alice
const avatarImg = screen.getAllByRole('img');
expect(avatarImg.length).toBeGreaterThan(0);
});
it('FE-COMP-BUDGET-036: expense_date shows dash when not set in read-only mode', async () => {
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Snack' }), total_price: 5, expense_date: null };
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('Snack');
// When expense_date is null, the fallback '—' is shown
const dashes = screen.getAllByText('—');
expect(dashes.length).toBeGreaterThan(0);
});
});
@@ -1,4 +1,4 @@
// FE-COMP-NOTIF-001 to FE-COMP-NOTIF-010
// FE-COMP-NOTIF-001 to FE-COMP-NOTIF-016
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { useAuthStore } from '../../store/authStore';
@@ -99,4 +99,109 @@ describe('InAppNotificationItem', () => {
// Recent notification shows "just now"
expect(screen.getByText('just now')).toBeInTheDocument();
});
it('FE-COMP-NOTIF-011: shows avatar image when sender_avatar is provided', () => {
render(
<InAppNotificationItem
notification={buildNotification({ sender_avatar: 'https://example.com/avatar.png' })}
/>
);
expect(document.querySelector('img')).toBeInTheDocument();
expect(document.querySelector('img')?.getAttribute('src')).toBe('https://example.com/avatar.png');
});
it('FE-COMP-NOTIF-012: boolean notification shows Accept and Reject buttons', () => {
render(
<InAppNotificationItem
notification={buildNotification({
type: 'boolean',
positive_text_key: 'common.yes',
negative_text_key: 'common.no',
})}
/>
);
expect(screen.getByText('Yes')).toBeInTheDocument();
expect(screen.getByText('No')).toBeInTheDocument();
});
it('FE-COMP-NOTIF-013: clicking Accept calls respondToBoolean with positive', async () => {
const user = userEvent.setup();
const respondToBoolean = vi.fn().mockResolvedValue(undefined);
seedStore(useInAppNotificationStore, { respondToBoolean });
render(
<InAppNotificationItem
notification={buildNotification({
id: 55,
type: 'boolean',
positive_text_key: 'common.yes',
negative_text_key: 'common.no',
response: null,
})}
/>
);
await user.click(screen.getByText('Yes'));
expect(respondToBoolean).toHaveBeenCalledWith(55, 'positive');
});
it('FE-COMP-NOTIF-014: clicking Reject calls respondToBoolean with negative', async () => {
const user = userEvent.setup();
const respondToBoolean = vi.fn().mockResolvedValue(undefined);
seedStore(useInAppNotificationStore, { respondToBoolean });
render(
<InAppNotificationItem
notification={buildNotification({
id: 66,
type: 'boolean',
positive_text_key: 'common.yes',
negative_text_key: 'common.no',
response: null,
})}
/>
);
await user.click(screen.getByText('No'));
expect(respondToBoolean).toHaveBeenCalledWith(66, 'negative');
});
it('FE-COMP-NOTIF-015: navigate notification shows action button', () => {
render(
<InAppNotificationItem
notification={buildNotification({
type: 'navigate',
navigate_text_key: 'notifications.title',
navigate_target: '/trips/1',
})}
/>
);
// t('notifications.title') = "Notifications" — the navigate button renders this
const navigateBtn = document.querySelector('button[style*="pointer"]') ??
Array.from(document.querySelectorAll('button')).find(b => b.textContent?.includes('Notifications'));
expect(navigateBtn).toBeInTheDocument();
});
it('FE-COMP-NOTIF-016: clicking navigate button marks read and navigates', async () => {
const user = userEvent.setup();
const markRead = vi.fn().mockResolvedValue(undefined);
const onClose = vi.fn();
seedStore(useInAppNotificationStore, { markRead });
render(
<InAppNotificationItem
notification={buildNotification({
id: 77,
type: 'navigate',
navigate_text_key: 'notifications.title',
navigate_target: '/trips/1',
is_read: 0,
})}
onClose={onClose}
/>
);
// The navigate button renders t('notifications.title') = "Notifications"
const btn = Array.from(document.querySelectorAll('button')).find(
b => b.textContent?.includes('Notifications')
);
expect(btn).toBeTruthy();
await user.click(btn!);
expect(markRead).toHaveBeenCalledWith(77);
expect(onClose).toHaveBeenCalled();
});
});
@@ -0,0 +1,119 @@
// FE-COMP-SCOPE-001 to FE-COMP-SCOPE-009
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { resetAllStores } from '../../../tests/helpers/store';
import ScopeGroupPicker from './ScopeGroupPicker';
beforeEach(() => {
resetAllStores();
});
describe('ScopeGroupPicker', () => {
it('FE-COMP-SCOPE-001: renders scope groups', () => {
render(<ScopeGroupPicker selected={[]} onChange={vi.fn()} />);
// Several group headers should be visible
expect(screen.getAllByRole('button').length).toBeGreaterThan(0);
});
it('FE-COMP-SCOPE-002: shows Select All button when nothing selected', () => {
render(<ScopeGroupPicker selected={[]} onChange={vi.fn()} />);
expect(screen.getByRole('button', { name: /select all/i })).toBeInTheDocument();
});
it('FE-COMP-SCOPE-003: Select All calls onChange with all scopes', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<ScopeGroupPicker selected={[]} onChange={onChange} />);
await user.click(screen.getByRole('button', { name: /select all/i }));
expect(onChange).toHaveBeenCalledTimes(1);
const called = onChange.mock.calls[0][0] as string[];
expect(called.length).toBeGreaterThan(0);
});
it('FE-COMP-SCOPE-004: shows Deselect All button when all selected', async () => {
// First collect all scopes by clicking Select All and capturing the callback
const user = userEvent.setup();
const captured: string[][] = [];
const { rerender } = render(
<ScopeGroupPicker selected={[]} onChange={s => captured.push(s)} />
);
await user.click(screen.getByRole('button', { name: /select all/i }));
const allScopes = captured[0];
// Now rerender with all scopes selected
rerender(<ScopeGroupPicker selected={allScopes} onChange={vi.fn()} />);
expect(screen.getByRole('button', { name: /deselect all/i })).toBeInTheDocument();
});
it('FE-COMP-SCOPE-005: Deselect All calls onChange with empty array', async () => {
const user = userEvent.setup();
const captured: string[][] = [];
// Get all scopes first
const { rerender } = render(
<ScopeGroupPicker selected={[]} onChange={s => captured.push(s)} />
);
await user.click(screen.getByRole('button', { name: /select all/i }));
const allScopes = captured[0];
const onChange = vi.fn();
rerender(<ScopeGroupPicker selected={allScopes} onChange={onChange} />);
await user.click(screen.getByRole('button', { name: /deselect all/i }));
expect(onChange).toHaveBeenCalledWith([]);
});
it('FE-COMP-SCOPE-006: expanding a group reveals individual scope checkboxes', async () => {
const user = userEvent.setup();
render(<ScopeGroupPicker selected={[]} onChange={vi.fn()} />);
// Groups are collapsed by default — checkboxes for individual scopes not visible
const groupToggles = screen.getAllByRole('button').filter(b =>
!b.textContent?.toLowerCase().includes('select all') &&
!b.textContent?.toLowerCase().includes('deselect all')
);
// Click the first group expand button
await user.click(groupToggles[0]);
// Individual scope checkboxes should now appear (more than just group-level ones)
const checkboxes = screen.getAllByRole('checkbox');
expect(checkboxes.length).toBeGreaterThan(0);
});
it('FE-COMP-SCOPE-007: group checkbox selects all scopes in the group', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<ScopeGroupPicker selected={[]} onChange={onChange} />);
const groupCheckboxes = screen.getAllByRole('checkbox');
await user.click(groupCheckboxes[0]);
expect(onChange).toHaveBeenCalledTimes(1);
const called = onChange.mock.calls[0][0] as string[];
expect(called.length).toBeGreaterThan(0);
});
it('FE-COMP-SCOPE-008: individual scope toggle adds/removes that scope', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<ScopeGroupPicker selected={[]} onChange={onChange} />);
// Expand first group
const groupToggles = screen.getAllByRole('button').filter(b =>
!b.textContent?.toLowerCase().includes('select all') &&
!b.textContent?.toLowerCase().includes('deselect all')
);
await user.click(groupToggles[0]);
// There are now individual scope checkboxes — click the second one (first is group-level)
const checkboxes = screen.getAllByRole('checkbox');
await user.click(checkboxes[1]); // individual scope
expect(onChange).toHaveBeenCalledTimes(1);
});
it('FE-COMP-SCOPE-009: count badge shown when some scopes selected in group', () => {
// Get any single scope key from the first group via Select All trick + manual slice
// We'll just select a scope by triggering group checkbox and passing it in
const firstGroupScope = 'trips:read'; // known scope from SCOPE_GROUPS
render(<ScopeGroupPicker selected={[firstGroupScope]} onChange={vi.fn()} />);
// Count badge like "(1/N)" should be visible
expect(screen.getByText(/\(\d+\/\d+\)/)).toBeInTheDocument();
});
});
@@ -1,4 +1,4 @@
// FE-COMP-INTEGRATIONS-001 to FE-COMP-INTEGRATIONS-018
// FE-COMP-INTEGRATIONS-001 to FE-COMP-INTEGRATIONS-032
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
@@ -7,6 +7,7 @@ import { useAuthStore } from '../../store/authStore';
import { useAddonStore } from '../../store/addonStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser } from '../../../tests/helpers/factories';
import { ToastContainer } from '../shared/Toast';
import IntegrationsTab from './IntegrationsTab';
function enableMcp() {
@@ -40,6 +41,8 @@ beforeEach(() => {
server.use(
http.get('/api/auth/mcp-tokens', () => HttpResponse.json({ tokens: [] })),
http.get('/api/addons', () => HttpResponse.json({ addons: [] })),
http.get('/api/oauth/clients', () => HttpResponse.json({ clients: [] })),
http.get('/api/oauth/sessions', () => HttpResponse.json({ sessions: [] })),
);
});
@@ -379,4 +382,273 @@ describe('IntegrationsTab', () => {
await screen.findByText(/Register OAuth 2\.1 clients/i);
expect(screen.queryByText('No tokens yet. Create one to connect MCP clients.')).toBeNull();
});
it('FE-COMP-INTEGRATIONS-021: OAuth client list renders when clients exist', async () => {
server.use(
http.get('/api/oauth/clients', () =>
HttpResponse.json({
clients: [
{
id: 'client-1',
client_id: 'clid-abc',
name: 'My OAuth App',
redirect_uris: ['http://localhost'],
allowed_scopes: ['trips:read', 'places:read'],
created_at: '2025-01-01T00:00:00Z',
},
],
})
)
);
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('My OAuth App');
expect(screen.getByText(/clid-abc/)).toBeInTheDocument();
});
it('FE-COMP-INTEGRATIONS-022: scope expansion toggle shows more/fewer scopes', async () => {
const user = userEvent.setup();
const scopes = ['trips:read', 'trips:write', 'places:read', 'places:write', 'budget:read', 'budget:write', 'packing:read'];
server.use(
http.get('/api/oauth/clients', () =>
HttpResponse.json({
clients: [
{ id: 'c1', client_id: 'cid', name: 'Big App', redirect_uris: ['http://localhost'], allowed_scopes: scopes, created_at: '2025-01-01T00:00:00Z' },
],
})
)
);
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('Big App');
// "+2 more" button visible (7 scopes, 5 shown)
const moreBtn = screen.getByText(/^\+\d+$/);
await user.click(moreBtn);
// Show less / collapse button now visible
expect(screen.getByText('')).toBeInTheDocument();
});
it('FE-COMP-INTEGRATIONS-023: active OAuth sessions section renders when sessions exist', async () => {
server.use(
http.get('/api/oauth/sessions', () =>
HttpResponse.json({
sessions: [
{
id: 10,
client_name: 'Claude Desktop',
scopes: ['trips:read'],
access_token_expires_at: '2025-12-31T00:00:00Z',
},
],
})
)
);
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('Claude Desktop');
expect(screen.getByText(/trips:read/)).toBeInTheDocument();
});
it('FE-COMP-INTEGRATIONS-024: Create OAuth Client modal opens and shows presets', async () => {
const user = userEvent.setup();
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await user.click(screen.getByRole('button', { name: /New Client/i }));
await screen.findByText('Register OAuth Client');
expect(screen.getByText('Claude.ai')).toBeInTheDocument();
expect(screen.getByText('Claude Desktop')).toBeInTheDocument();
});
it('FE-COMP-INTEGRATIONS-025: clicking a preset fills form fields', async () => {
const user = userEvent.setup();
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await user.click(screen.getByRole('button', { name: /New Client/i }));
await screen.findByText('Register OAuth Client');
// Presets render as buttons — click "Claude.ai" preset
const presetBtns = screen.getAllByRole('button', { name: /Claude\.ai/i });
await user.click(presetBtns[0]);
// Name field should be filled with 'Claude.ai'
const nameInput = screen.getByPlaceholderText(/Claude Web, My MCP App/i);
expect((nameInput as HTMLInputElement).value).toBe('Claude.ai');
});
it('FE-COMP-INTEGRATIONS-026: creating client shows success view with client_id and secret', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/oauth/clients', () =>
HttpResponse.json({
client: {
id: 'new-id',
client_id: 'clid-new',
client_secret: 'secret-value',
name: 'Test Client',
redirect_uris: ['http://localhost'],
allowed_scopes: ['trips:read'],
created_at: '2025-01-01T00:00:00Z',
},
})
)
);
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await user.click(screen.getByRole('button', { name: /New Client/i }));
await screen.findByText('Register OAuth Client');
const nameInput = screen.getByPlaceholderText(/Claude Web, My MCP App/i);
await user.type(nameInput, 'Test Client');
const uriInput = screen.getByPlaceholderText(/https:\/\/your-app/i);
await user.type(uriInput, 'http://localhost');
await user.click(screen.getByRole('button', { name: /Register Client/i }));
// Success view shows client credentials (there may be multiple matches in list + modal)
await screen.findAllByText(/clid-new/);
const secretEls = await screen.findAllByText(/secret-value/);
expect(secretEls.length).toBeGreaterThan(0);
});
it('FE-COMP-INTEGRATIONS-027: Done button closes created-client modal', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/oauth/clients', () =>
HttpResponse.json({
client: {
id: 'n2',
client_id: 'clid-n2',
client_secret: 'secret-n2',
name: 'TC2',
redirect_uris: ['http://localhost'],
allowed_scopes: ['trips:read'],
created_at: '2025-01-01T00:00:00Z',
},
})
)
);
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await user.click(screen.getByRole('button', { name: /New Client/i }));
await screen.findByText('Register OAuth Client');
await user.type(screen.getByPlaceholderText(/Claude Web, My MCP App/i), 'TC2');
await user.type(screen.getByPlaceholderText(/https:\/\/your-app/i), 'http://localhost');
await user.click(screen.getByRole('button', { name: /Register Client/i }));
await screen.findAllByText(/clid-n2/);
// Check the "Client Registered" modal title is visible before Done
expect(screen.getByText('Client Registered')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /^Done$/i }));
await waitFor(() => {
expect(screen.queryByText('Client Registered')).toBeNull();
});
});
it('FE-COMP-INTEGRATIONS-028: delete OAuth client confirmation removes client from list', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/oauth/clients', () =>
HttpResponse.json({
clients: [
{ id: 'del-1', client_id: 'cid-del', name: 'Delete Me', redirect_uris: ['http://localhost'], allowed_scopes: ['trips:read'], created_at: '2025-01-01T00:00:00Z' },
],
})
),
http.delete('/api/oauth/clients/del-1', () => HttpResponse.json({ success: true }))
);
enableMcp();
render(<><ToastContainer /><IntegrationsTab /></>);
await screen.findByText('Delete Me');
await user.click(screen.getByTitle('Delete Client'));
// Confirmation modal
await screen.findByRole('heading', { name: 'Delete Client' });
const confirmBtns = screen.getAllByRole('button', { name: /Delete Client/i });
// Modal confirm button is last in DOM (modal renders after list)
await user.click(confirmBtns[confirmBtns.length - 1]);
await waitFor(() => {
expect(screen.queryByText('Delete Me')).toBeNull();
});
});
it('FE-COMP-INTEGRATIONS-029: rotate secret confirmation shows new secret', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/oauth/clients', () =>
HttpResponse.json({
clients: [
{ id: 'rot-1', client_id: 'cid-rot', name: 'Rotate Me', redirect_uris: ['http://localhost'], allowed_scopes: ['trips:read'], created_at: '2025-01-01T00:00:00Z' },
],
})
),
http.post('/api/oauth/clients/rot-1/rotate', () =>
HttpResponse.json({ client_secret: 'new-rotated-secret' })
)
);
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('Rotate Me');
await user.click(screen.getByTitle('Rotate Secret'));
await screen.findByText('Rotate Secret');
// Confirm — button text is 'Rotate'
const rotateBtns = screen.getAllByRole('button', { name: /^Rotate$/i });
await user.click(rotateBtns[rotateBtns.length - 1]);
await screen.findByText(/new-rotated-secret/);
});
it('FE-COMP-INTEGRATIONS-030: revoke OAuth session removes it from list', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/oauth/sessions', () =>
HttpResponse.json({
sessions: [
{ id: 99, client_name: 'Revoke App', scopes: ['trips:read'], access_token_expires_at: '2025-12-31T00:00:00Z' },
],
})
),
http.delete('/api/oauth/sessions/99', () => HttpResponse.json({ success: true }))
);
enableMcp();
render(<><ToastContainer /><IntegrationsTab /></>);
await screen.findByText('Revoke App');
await user.click(screen.getByText('Revoke'));
// Confirmation modal
await screen.findByText('Revoke Session');
const revokeBtns = screen.getAllByRole('button', { name: /^Revoke$/i });
// Modal confirm button is last in DOM
await user.click(revokeBtns[revokeBtns.length - 1]);
await waitFor(() => {
expect(screen.queryByText('Revoke App')).toBeNull();
});
});
it('FE-COMP-INTEGRATIONS-031: Register Client button disabled when name or URI is empty', async () => {
const user = userEvent.setup();
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await user.click(screen.getByRole('button', { name: /New Client/i }));
await screen.findByText('Register OAuth Client');
const createBtn = screen.getByRole('button', { name: /Register Client/i });
expect(createBtn).toBeDisabled();
// Type only name, not URI → still disabled
await user.type(screen.getByPlaceholderText(/Claude Web, My MCP App/i), 'Test');
expect(createBtn).toBeDisabled();
});
it('FE-COMP-INTEGRATIONS-032: error toast shown when create OAuth client fails', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/oauth/clients', () =>
HttpResponse.json({ error: 'server error' }, { status: 500 })
)
);
enableMcp();
render(<><ToastContainer /><IntegrationsTab /></>);
await screen.findByText('MCP Configuration');
await user.click(screen.getByRole('button', { name: /New Client/i }));
await screen.findByText('Register OAuth Client');
await user.type(screen.getByPlaceholderText(/Claude Web, My MCP App/i), 'Fail Client');
await user.type(screen.getByPlaceholderText(/https:\/\/your-app/i), 'http://localhost');
await user.click(screen.getByRole('button', { name: /Register Client/i }));
await screen.findByText(/Failed to register/i);
});
});
+6
View File
@@ -18,6 +18,12 @@ beforeEach(() => {
seedStore(usePermissionsStore, {
level: 'owner',
} as any);
// Intercept CurrencyWidget's external fetch so it resolves before teardown
server.use(
http.get('https://api.exchangerate-api.com/v4/latest/:currency', () => {
return HttpResponse.json({ rates: { USD: 1.08, EUR: 1, CHF: 0.97 } });
}),
);
});
describe('DashboardPage', () => {
@@ -0,0 +1,199 @@
// FE-PAGE-OAUTH-001 to FE-PAGE-OAUTH-012
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 { resetAllStores, seedStore } from '../../tests/helpers/store';
import { buildUser } from '../../tests/helpers/factories';
import OAuthAuthorizePage from './OAuthAuthorizePage';
// Default OAuth query params
const DEFAULT_SEARCH = '?client_id=test-client&redirect_uri=http%3A%2F%2Flocalhost%3A4000%2Fcallback&scope=trips%3Aread&state=abc&code_challenge=challenge&code_challenge_method=S256';
function setSearchParams(search: string) {
window.history.pushState({}, '', '/oauth/authorize' + search);
}
const VALIDATE_OK = {
valid: true,
client: { name: 'Test App', allowed_scopes: ['trips:read'] },
scopes: ['trips:read'],
consentRequired: true,
loginRequired: false,
scopeSelectable: false,
};
beforeEach(() => {
resetAllStores();
setSearchParams(DEFAULT_SEARCH);
server.resetHandlers();
// Default: authenticated user
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, isLoading: false });
// Default validate: consent required
server.use(
http.get('/api/oauth/authorize/validate', () => HttpResponse.json(VALIDATE_OK)),
http.post('/api/oauth/authorize', () =>
HttpResponse.json({ redirect: 'http://localhost:4000/callback?code=abc' })
),
);
});
afterEach(() => {
window.history.pushState({}, '', '/');
});
describe('OAuthAuthorizePage', () => {
it('FE-PAGE-OAUTH-001: shows loading spinner initially', () => {
server.use(
http.get('/api/oauth/authorize/validate', async () => {
await new Promise(() => {}); // never resolves
return HttpResponse.json(VALIDATE_OK);
})
);
render(<OAuthAuthorizePage />);
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
});
it('FE-PAGE-OAUTH-002: shows error state when validation fails', async () => {
server.use(
http.get('/api/oauth/authorize/validate', () =>
HttpResponse.json({
valid: false,
error: 'invalid_client',
error_description: 'Unknown client ID',
})
)
);
render(<OAuthAuthorizePage />);
await screen.findByText('Authorization Error');
expect(screen.getByText('Unknown client ID')).toBeInTheDocument();
});
it('FE-PAGE-OAUTH-003: shows error state on network error', async () => {
server.use(
http.get('/api/oauth/authorize/validate', () =>
HttpResponse.json({ error: 'server error' }, { status: 500 })
)
);
render(<OAuthAuthorizePage />);
await screen.findByText('Authorization Error');
expect(screen.getByText(/Failed to validate/i)).toBeInTheDocument();
});
it('FE-PAGE-OAUTH-004: shows login_required state', async () => {
server.use(
http.get('/api/oauth/authorize/validate', () =>
HttpResponse.json({ ...VALIDATE_OK, loginRequired: true, consentRequired: true })
)
);
render(<OAuthAuthorizePage />);
await screen.findByText('Sign in to continue');
expect(screen.getByText('Sign in to TREK')).toBeInTheDocument();
});
it('FE-PAGE-OAUTH-005: shows client name in login_required state', async () => {
server.use(
http.get('/api/oauth/authorize/validate', () =>
HttpResponse.json({ ...VALIDATE_OK, loginRequired: true })
)
);
render(<OAuthAuthorizePage />);
await screen.findByText('Sign in to continue');
expect(screen.getByText(/Test App/)).toBeInTheDocument();
});
it('FE-PAGE-OAUTH-006: shows consent form with client name and scope list', async () => {
render(<OAuthAuthorizePage />);
await screen.findByText('Test App');
expect(screen.getByText('Authorization Request')).toBeInTheDocument();
expect(screen.getByText('Approve Access')).toBeInTheDocument();
expect(screen.getByText('Deny')).toBeInTheDocument();
});
it('FE-PAGE-OAUTH-007: auto-approves when consentRequired is false', async () => {
let authorizeCalled = false;
server.use(
http.get('/api/oauth/authorize/validate', () =>
HttpResponse.json({ ...VALIDATE_OK, consentRequired: false })
),
http.post('/api/oauth/authorize', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
authorizeCalled = true;
expect(body.approved).toBe(true);
return HttpResponse.json({ redirect: 'http://localhost:4000/callback?code=xyz' });
})
);
render(<OAuthAuthorizePage />);
// Shows auto-approving spinner
await waitFor(() => {
expect(authorizeCalled).toBe(true);
});
});
it('FE-PAGE-OAUTH-008: clicking Deny sends approved=false to authorize', async () => {
const user = userEvent.setup();
let body: Record<string, unknown> = {};
server.use(
http.post('/api/oauth/authorize', async ({ request }) => {
body = await request.json() as Record<string, unknown>;
return HttpResponse.json({ redirect: 'http://localhost:4000/callback?error=access_denied' });
})
);
render(<OAuthAuthorizePage />);
await screen.findByText('Deny');
await user.click(screen.getByText('Deny'));
await waitFor(() => {
expect(body.approved).toBe(false);
});
});
it('FE-PAGE-OAUTH-009: clicking Approve sends approved=true with selected scopes', async () => {
const user = userEvent.setup();
let body: Record<string, unknown> = {};
server.use(
http.post('/api/oauth/authorize', async ({ request }) => {
body = await request.json() as Record<string, unknown>;
return HttpResponse.json({ redirect: 'http://localhost:4000/callback?code=ok' });
})
);
render(<OAuthAuthorizePage />);
await screen.findByText('Approve Access');
await user.click(screen.getByText('Approve Access'));
await waitFor(() => {
expect(body.approved).toBe(true);
});
});
it('FE-PAGE-OAUTH-010: shows error when authorize call fails', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/oauth/authorize', () =>
HttpResponse.json({ error: 'server error' }, { status: 500 })
)
);
render(<OAuthAuthorizePage />);
await screen.findByText('Approve Access');
await user.click(screen.getByText('Approve Access'));
await screen.findByText('Authorization Error');
expect(screen.getByText(/Authorization failed/i)).toBeInTheDocument();
});
it('FE-PAGE-OAUTH-011: scopeSelectable=true renders checkboxes for scopes', async () => {
server.use(
http.get('/api/oauth/authorize/validate', () =>
HttpResponse.json({ ...VALIDATE_OK, scopeSelectable: true, scopes: ['trips:read', 'places:read'] })
)
);
render(<OAuthAuthorizePage />);
await screen.findByText('Choose which permissions to grant');
expect(screen.getAllByRole('checkbox').length).toBeGreaterThan(0);
});
it('FE-PAGE-OAUTH-012: scopeSelectable=false renders read-only scope list', async () => {
render(<OAuthAuthorizePage />);
await screen.findByText('Permissions requested');
// No checkboxes in read-only mode
expect(screen.queryAllByRole('checkbox')).toHaveLength(0);
});
});
+166 -1
View File
@@ -65,8 +65,12 @@ vi.mock('../components/Planner/PlacesSidebar', () => ({
},
}));
const capturedPlaceInspectorProps: { current: Record<string, any> } = { current: {} };
vi.mock('../components/Planner/PlaceInspector', () => ({
default: () => null,
default: (props: Record<string, any>) => {
capturedPlaceInspectorProps.current = props;
return React.createElement('div', { 'data-testid': 'place-inspector' });
},
}));
const capturedDayDetailPanelProps: { current: Record<string, any> } = { current: {} };
@@ -232,6 +236,7 @@ beforeEach(() => {
capturedTripFormModalProps.current = {};
capturedTripMembersModalProps.current = {};
capturedFileManagerProps.current = {};
capturedPlaceInspectorProps.current = {};
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() });
});
@@ -1334,6 +1339,166 @@ describe('TripPlannerPage', () => {
});
});
describe('FE-PAGE-PLANNER-046: Invalid session tab resets to plan', () => {
it('resets activeTab to "plan" when saved tab is no longer in TRIP_TABS', async () => {
// Save a tab id that requires the "memories" addon (disabled by default)
sessionStorage.setItem('trip-tab-42', 'memories');
seedTripStore({ id: 42 });
renderPlannerPage(42);
// The useEffect should detect the invalid tab and reset it
await waitFor(() => {
expect(sessionStorage.getItem('trip-tab-42')).toBe('plan');
});
});
});
describe('FE-PAGE-PLANNER-047: Desktop PlaceInspector onEdit with selectedAssignment', () => {
it('calls onEdit on desktop PlaceInspector with selectedAssignmentId to cover if-branch', async () => {
vi.useFakeTimers();
const place = buildPlace({ id: 1, trip_id: 42, lat: 48.8566, lng: 2.3522 });
const assignment = buildAssignment({ id: 10, day_id: 99, place, order_index: 0 });
mockPlaceSelectionState.selectedPlaceId = place.id;
mockPlaceSelectionState.selectedAssignmentId = assignment.id;
seedTripStore({ id: 42 });
seedStore(useTripStore, {
places: [place],
assignments: { '99': [assignment] },
} as any);
renderPlannerPage(42);
act(() => { vi.runAllTimers(); });
vi.useRealTimers();
await waitFor(() => {
expect(screen.getByTestId('place-inspector')).toBeInTheDocument();
});
// onEdit with selectedAssignmentId set — covers lines 795-798 (if branch)
await act(async () => {
capturedPlaceInspectorProps.current.onEdit?.();
});
});
});
describe('FE-PAGE-PLANNER-048: Mobile PlaceInspector portal renders when isMobile is true', () => {
it('renders PlaceInspector in mobile portal and covers mobile callbacks', async () => {
vi.useFakeTimers();
// Simulate mobile viewport
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 375 });
const place = buildPlace({ id: 1, trip_id: 42, lat: 48.8566, lng: 2.3522 });
mockPlaceSelectionState.selectedPlaceId = place.id;
seedTripStore({ id: 42 });
seedStore(useTripStore, { places: [place] } as any);
renderPlannerPage(42);
act(() => { vi.runAllTimers(); });
vi.useRealTimers();
// Mobile portal renders the PlaceInspector (lines 830-879)
await waitFor(() => {
expect(screen.getByTestId('place-inspector')).toBeInTheDocument();
});
// onEdit without assignment — covers else branch at line 799
await act(async () => {
capturedPlaceInspectorProps.current.onEdit?.();
});
// onClose — covers mobile onClose lambda
await act(async () => {
capturedPlaceInspectorProps.current.onClose?.();
});
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1024 });
});
});
describe('FE-PAGE-PLANNER-049: Mobile sidebar left panel opens via Plan button', () => {
it('clicking the mobile Plan button opens the left sidebar portal (lines 882-893)', async () => {
vi.useFakeTimers();
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 375 });
seedTripStore({ id: 42 });
renderPlannerPage(42);
act(() => { vi.runAllTimers(); });
vi.useRealTimers();
await waitFor(() => {
expect(screen.getByTestId('day-plan-sidebar')).toBeInTheDocument();
});
// The mobile portal buttons are rendered to document.body.
// The "Plan" tab button has title="Plan"; the mobile portal button does not.
const mobilePlanBtn = Array.from(document.body.querySelectorAll('button')).find(
b => b.textContent === 'Plan' && !b.getAttribute('title'),
);
if (mobilePlanBtn) {
await act(async () => { fireEvent.click(mobilePlanBtn); });
// Mobile sidebar portal renders DayPlanSidebar — now two instances
await waitFor(() => {
expect(screen.getAllByTestId('day-plan-sidebar').length).toBeGreaterThanOrEqual(2);
});
// Close the mobile sidebar via the X button inside the portal header
const closeButtons = Array.from(document.body.querySelectorAll('button')).filter(
b => !b.textContent || b.textContent.trim() === '',
);
if (closeButtons.length > 0) {
await act(async () => { fireEvent.click(closeButtons[0]); });
}
}
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1024 });
});
});
describe('FE-PAGE-PLANNER-050: Mobile sidebar right panel opens via Places button', () => {
it('clicking the mobile Places button opens the right sidebar portal (lines 894)', async () => {
vi.useFakeTimers();
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 375 });
seedTripStore({ id: 42 });
renderPlannerPage(42);
act(() => { vi.runAllTimers(); });
vi.useRealTimers();
await waitFor(() => {
expect(screen.getByTestId('places-sidebar')).toBeInTheDocument();
});
// "Places" tab doesn't exist; the mobile portal "Places" button has no title
const mobilePlacesBtn = Array.from(document.body.querySelectorAll('button')).find(
b => b.textContent === 'Places' && !b.getAttribute('title'),
);
if (mobilePlacesBtn) {
await act(async () => { fireEvent.click(mobilePlacesBtn); });
// PlacesSidebar renders in mobile sidebar portal
await waitFor(() => {
expect(screen.getAllByTestId('places-sidebar').length).toBeGreaterThanOrEqual(2);
});
}
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1024 });
});
});
describe('FE-PAGE-PLANNER-037: onExpandedDaysChange covers mapPlaces hidden logic', () => {
it('calls onExpandedDaysChange to trigger mapPlaces hidden set computation', async () => {
vi.useFakeTimers();
+177
View File
@@ -0,0 +1,177 @@
// FE-STORE-BUDGET-001 to FE-STORE-BUDGET-011
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildBudgetItem } from '../../../tests/helpers/factories';
import { useTripStore } from '../tripStore';
beforeEach(() => {
resetAllStores();
server.resetHandlers();
});
describe('budgetSlice', () => {
it('FE-STORE-BUDGET-001: loadBudgetItems populates store', async () => {
const item = buildBudgetItem({ trip_id: 1 });
server.use(
http.get('/api/trips/1/budget', () =>
HttpResponse.json({ items: [item] })
)
);
await useTripStore.getState().loadBudgetItems(1);
expect(useTripStore.getState().budgetItems).toHaveLength(1);
expect(useTripStore.getState().budgetItems[0].id).toBe(item.id);
});
it('FE-STORE-BUDGET-002: loadBudgetItems swallows errors silently', async () => {
server.use(
http.get('/api/trips/1/budget', () =>
HttpResponse.json({ error: 'server error' }, { status: 500 })
)
);
// Should NOT throw
await expect(useTripStore.getState().loadBudgetItems(1)).resolves.toBeUndefined();
expect(useTripStore.getState().budgetItems).toEqual([]);
});
it('FE-STORE-BUDGET-003: addBudgetItem appends to store and returns item', async () => {
const newItem = buildBudgetItem({ name: 'Hotel', trip_id: 1 });
server.use(
http.post('/api/trips/1/budget', () =>
HttpResponse.json({ item: newItem })
)
);
const result = await useTripStore.getState().addBudgetItem(1, { name: 'Hotel' });
expect(result.id).toBe(newItem.id);
expect(useTripStore.getState().budgetItems).toContainEqual(newItem);
});
it('FE-STORE-BUDGET-004: addBudgetItem throws on API error', async () => {
server.use(
http.post('/api/trips/1/budget', () =>
HttpResponse.json({ error: 'Validation failed' }, { status: 422 })
)
);
await expect(useTripStore.getState().addBudgetItem(1, {})).rejects.toThrow();
});
it('FE-STORE-BUDGET-005: updateBudgetItem replaces item in store', async () => {
const existing = buildBudgetItem({ id: 10, trip_id: 1, name: 'Old' });
seedStore(useTripStore, { budgetItems: [existing] });
const updated = { ...existing, name: 'New' };
server.use(
http.put('/api/trips/1/budget/10', () =>
HttpResponse.json({ item: updated })
)
);
await useTripStore.getState().updateBudgetItem(1, 10, { name: 'New' });
const items = useTripStore.getState().budgetItems;
expect(items).toHaveLength(1);
expect(items[0].name).toBe('New');
});
it('FE-STORE-BUDGET-006: updateBudgetItem calls loadReservations when reservation_id + total_price provided', async () => {
const existing = buildBudgetItem({ id: 20, trip_id: 1 });
seedStore(useTripStore, { budgetItems: [existing] });
const loadReservations = vi.fn().mockResolvedValue(undefined);
seedStore(useTripStore, { loadReservations });
const itemWithReservation = { ...existing, reservation_id: 99 };
server.use(
http.put('/api/trips/1/budget/20', () =>
HttpResponse.json({ item: itemWithReservation })
)
);
await useTripStore.getState().updateBudgetItem(1, 20, { total_price: 50 });
expect(loadReservations).toHaveBeenCalledWith(1);
});
it('FE-STORE-BUDGET-007: deleteBudgetItem optimistically removes and rolls back on error', async () => {
const item = buildBudgetItem({ id: 5, trip_id: 1 });
seedStore(useTripStore, { budgetItems: [item] });
server.use(
http.delete('/api/trips/1/budget/5', () =>
HttpResponse.json({ error: 'forbidden' }, { status: 403 })
)
);
// The item is removed immediately (optimistic), then restored on error
const deletePromise = useTripStore.getState().deleteBudgetItem(1, 5);
await expect(deletePromise).rejects.toThrow();
// After rollback, item is back
expect(useTripStore.getState().budgetItems).toContainEqual(item);
});
it('FE-STORE-BUDGET-008: setBudgetItemMembers updates members on matching item', async () => {
const item = buildBudgetItem({ id: 7, trip_id: 1, members: [] });
seedStore(useTripStore, { budgetItems: [item] });
const members = [{ user_id: 1, paid: false }, { user_id: 2, paid: false }];
const updatedItem = { ...item, persons: 2, members };
server.use(
http.put('/api/trips/1/budget/7/members', () =>
HttpResponse.json({ members, item: updatedItem })
)
);
await useTripStore.getState().setBudgetItemMembers(1, 7, [1, 2]);
const stored = useTripStore.getState().budgetItems.find(i => i.id === 7);
expect(stored?.members).toHaveLength(2);
expect(stored?.persons).toBe(2);
});
it('FE-STORE-BUDGET-009: toggleBudgetMemberPaid updates paid flag on matching member', async () => {
const item = buildBudgetItem({
id: 8,
trip_id: 1,
members: [{ user_id: 3, paid: false }],
});
seedStore(useTripStore, { budgetItems: [item] });
server.use(
http.put('/api/trips/1/budget/8/members/3/paid', () =>
HttpResponse.json({ success: true, paid: true })
)
);
await useTripStore.getState().toggleBudgetMemberPaid(1, 8, 3, true);
const stored = useTripStore.getState().budgetItems.find(i => i.id === 8);
expect(stored?.members?.[0]?.paid).toBe(true);
});
it('FE-STORE-BUDGET-010: reorderBudgetItems reorders optimistically and reloads on error', async () => {
const a = buildBudgetItem({ id: 1, trip_id: 1 });
const b = buildBudgetItem({ id: 2, trip_id: 1 });
seedStore(useTripStore, { budgetItems: [a, b] });
// Reorder succeeds
server.use(
http.put('/api/trips/1/budget/reorder/items', () =>
HttpResponse.json({ success: true })
)
);
await useTripStore.getState().reorderBudgetItems(1, [2, 1]);
const items = useTripStore.getState().budgetItems;
expect(items[0].id).toBe(2);
expect(items[1].id).toBe(1);
});
it('FE-STORE-BUDGET-011: reorderBudgetItems reloads list on API error', async () => {
const a = buildBudgetItem({ id: 1, trip_id: 1 });
const b = buildBudgetItem({ id: 2, trip_id: 1 });
seedStore(useTripStore, { budgetItems: [a, b] });
const freshItem = buildBudgetItem({ id: 99, trip_id: 1 });
server.use(
http.put('/api/trips/1/budget/reorder/items', () =>
HttpResponse.json({ error: 'error' }, { status: 500 })
),
http.get('/api/trips/1/budget', () =>
HttpResponse.json({ items: [freshItem] })
)
);
await useTripStore.getState().reorderBudgetItems(1, [2, 1]);
// After failure, fresh list from server
expect(useTripStore.getState().budgetItems[0].id).toBe(freshItem.id);
});
});
@@ -92,6 +92,18 @@ export const adminHandlers = [
return HttpResponse.json({ tokens: [] });
}),
http.get('/api/admin/oauth-sessions', () => {
return HttpResponse.json({ sessions: [] });
}),
http.delete('/api/admin/oauth-sessions/:id', () => {
return HttpResponse.json({ success: true });
}),
http.delete('/api/admin/mcp-tokens/:id', () => {
return HttpResponse.json({ success: true });
}),
http.get('/api/admin/permissions', () => {
return HttpResponse.json({ permissions: {} });
}),