mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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: {} });
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user