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 { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
@@ -197,4 +197,127 @@ describe('AdminMcpTokensPanel', () => {
render(<><ToastContainer /><AdminMcpTokensPanel /></>); render(<><ToastContainer /><AdminMcpTokensPanel /></>);
await screen.findByText('Failed to load tokens'); 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 { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
@@ -6,6 +6,7 @@ import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore'; import { useTripStore } from '../../store/tripStore';
import { useSettingsStore } from '../../store/settingsStore'; import { useSettingsStore } from '../../store/settingsStore';
import { usePermissionsStore } from '../../store/permissionsStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip, buildBudgetItem, buildSettings } from '../../../tests/helpers/factories'; import { buildUser, buildTrip, buildBudgetItem, buildSettings } from '../../../tests/helpers/factories';
import BudgetPanel from './BudgetPanel'; import BudgetPanel from './BudgetPanel';
@@ -418,4 +419,80 @@ describe('BudgetPanel', () => {
// Grand total card shows 300.00 // Grand total card shows 300.00
expect(screen.getByText('300.00')).toBeInTheDocument(); 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 { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
@@ -99,4 +99,109 @@ describe('InAppNotificationItem', () => {
// Recent notification shows "just now" // Recent notification shows "just now"
expect(screen.getByText('just now')).toBeInTheDocument(); 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 { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
@@ -7,6 +7,7 @@ import { useAuthStore } from '../../store/authStore';
import { useAddonStore } from '../../store/addonStore'; import { useAddonStore } from '../../store/addonStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser } from '../../../tests/helpers/factories'; import { buildUser } from '../../../tests/helpers/factories';
import { ToastContainer } from '../shared/Toast';
import IntegrationsTab from './IntegrationsTab'; import IntegrationsTab from './IntegrationsTab';
function enableMcp() { function enableMcp() {
@@ -40,6 +41,8 @@ beforeEach(() => {
server.use( server.use(
http.get('/api/auth/mcp-tokens', () => HttpResponse.json({ tokens: [] })), http.get('/api/auth/mcp-tokens', () => HttpResponse.json({ tokens: [] })),
http.get('/api/addons', () => HttpResponse.json({ addons: [] })), 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); await screen.findByText(/Register OAuth 2\.1 clients/i);
expect(screen.queryByText('No tokens yet. Create one to connect MCP clients.')).toBeNull(); 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, { seedStore(usePermissionsStore, {
level: 'owner', level: 'owner',
} as any); } 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', () => { 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', () => ({ 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: {} }; const capturedDayDetailPanelProps: { current: Record<string, any> } = { current: {} };
@@ -232,6 +236,7 @@ beforeEach(() => {
capturedTripFormModalProps.current = {}; capturedTripFormModalProps.current = {};
capturedTripMembersModalProps.current = {}; capturedTripMembersModalProps.current = {};
capturedFileManagerProps.current = {}; capturedFileManagerProps.current = {};
capturedPlaceInspectorProps.current = {};
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() }); 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', () => { describe('FE-PAGE-PLANNER-037: onExpandedDaysChange covers mapPlaces hidden logic', () => {
it('calls onExpandedDaysChange to trigger mapPlaces hidden set computation', async () => { it('calls onExpandedDaysChange to trigger mapPlaces hidden set computation', async () => {
vi.useFakeTimers(); 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: [] }); 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', () => { http.get('/api/admin/permissions', () => {
return HttpResponse.json({ permissions: {} }); return HttpResponse.json({ permissions: {} });
}), }),
+146
View File
@@ -528,4 +528,150 @@ describe('MCP token management', () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(Array.isArray(res.body.tokens)).toBe(true); expect(Array.isArray(res.body.tokens)).toBe(true);
}); });
it('ADMIN-024 — DELETE /admin/mcp-tokens/:id returns 404 for missing token', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.delete('/api/admin/mcp-tokens/99999')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(404);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// OAuth sessions
// ─────────────────────────────────────────────────────────────────────────────
describe('OAuth sessions', () => {
it('ADMIN-025 — GET /admin/oauth-sessions returns list', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.get('/api/admin/oauth-sessions')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(200);
expect(Array.isArray(res.body.sessions)).toBe(true);
});
it('ADMIN-026 — DELETE /admin/oauth-sessions/:id returns 404 for missing session', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.delete('/api/admin/oauth-sessions/99999')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(404);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// OIDC settings
// ─────────────────────────────────────────────────────────────────────────────
describe('OIDC settings', () => {
it('ADMIN-027 — GET /admin/oidc returns OIDC configuration', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.get('/api/admin/oidc')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(200);
});
it('ADMIN-028 — PUT /admin/oidc updates OIDC settings', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.put('/api/admin/oidc')
.set('Cookie', authCookie(admin.id))
.send({ issuer: 'https://accounts.example.com', client_id: 'my-client', oidc_only: false });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Demo baseline
// ─────────────────────────────────────────────────────────────────────────────
describe('Demo baseline', () => {
it('ADMIN-029 — POST /admin/save-demo-baseline returns 404 when DEMO_MODE is not set', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.post('/api/admin/save-demo-baseline')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(404);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// GitHub releases / version check
// ─────────────────────────────────────────────────────────────────────────────
describe('GitHub releases and version check', () => {
it('ADMIN-030 — GET /admin/github-releases returns array (even if GitHub unreachable)', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.get('/api/admin/github-releases?per_page=5&page=1')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
it('ADMIN-031 — GET /admin/version-check returns version info', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.get('/api/admin/version-check')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('current');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Additional list routes
// ─────────────────────────────────────────────────────────────────────────────
describe('Admin list routes', () => {
it('ADMIN-032 — GET /admin/invites lists invites', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.get('/api/admin/invites')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(200);
expect(Array.isArray(res.body.invites)).toBe(true);
});
it('ADMIN-033 — GET /admin/bag-tracking returns bag tracking setting', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.get('/api/admin/bag-tracking')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(200);
});
it('ADMIN-034 — GET /admin/packing-templates lists templates', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.get('/api/admin/packing-templates')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(200);
expect(Array.isArray(res.body.templates)).toBe(true);
});
it('ADMIN-035 — GET /admin/addons lists addons', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.get('/api/admin/addons')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(200);
expect(Array.isArray(res.body.addons)).toBe(true);
});
}); });
@@ -0,0 +1,121 @@
/**
* Unit tests for MCP sessionManager — SESS-001 to SESS-010.
* Covers revokeUserSessions and revokeUserSessionsForClient.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { sessions, revokeUserSessions, revokeUserSessionsForClient, McpSession } from '../../../src/mcp/sessionManager';
function makeSession(overrides: Partial<McpSession> = {}): McpSession {
return {
server: { close: vi.fn() } as any,
transport: { close: vi.fn() } as any,
userId: 1,
scopes: null,
clientId: null,
isStaticToken: false,
lastActivity: Date.now(),
...overrides,
};
}
beforeEach(() => {
sessions.clear();
});
describe('revokeUserSessions', () => {
it('SESS-001: removes all sessions for the given userId', () => {
sessions.set('sid-1', makeSession({ userId: 1 }));
sessions.set('sid-2', makeSession({ userId: 1 }));
sessions.set('sid-3', makeSession({ userId: 2 }));
revokeUserSessions(1);
expect(sessions.has('sid-1')).toBe(false);
expect(sessions.has('sid-2')).toBe(false);
expect(sessions.has('sid-3')).toBe(true);
});
it('SESS-002: calls server.close() and transport.close() for each revoked session', () => {
const s = makeSession({ userId: 1 });
sessions.set('sid-1', s);
revokeUserSessions(1);
expect(s.server.close).toHaveBeenCalledOnce();
expect(s.transport.close).toHaveBeenCalledOnce();
});
it('SESS-003: does nothing when no sessions match userId', () => {
sessions.set('sid-1', makeSession({ userId: 2 }));
revokeUserSessions(99);
expect(sessions.has('sid-1')).toBe(true);
});
it('SESS-004: does nothing when sessions map is empty', () => {
expect(() => revokeUserSessions(1)).not.toThrow();
expect(sessions.size).toBe(0);
});
it('SESS-005: tolerates server.close() throwing (swallows error)', () => {
const s = makeSession({ userId: 1 });
(s.server.close as ReturnType<typeof vi.fn>).mockImplementation(() => { throw new Error('close failed'); });
sessions.set('sid-1', s);
expect(() => revokeUserSessions(1)).not.toThrow();
expect(sessions.has('sid-1')).toBe(false);
});
it('SESS-006: tolerates transport.close() throwing (swallows error)', () => {
const s = makeSession({ userId: 1 });
(s.transport.close as ReturnType<typeof vi.fn>).mockImplementation(() => { throw new Error('transport error'); });
sessions.set('sid-1', s);
expect(() => revokeUserSessions(1)).not.toThrow();
expect(sessions.has('sid-1')).toBe(false);
});
});
describe('revokeUserSessionsForClient', () => {
it('SESS-007: removes only sessions matching both userId and clientId', () => {
sessions.set('sid-1', makeSession({ userId: 1, clientId: 'client-a' }));
sessions.set('sid-2', makeSession({ userId: 1, clientId: 'client-b' }));
sessions.set('sid-3', makeSession({ userId: 2, clientId: 'client-a' }));
revokeUserSessionsForClient(1, 'client-a');
expect(sessions.has('sid-1')).toBe(false);
expect(sessions.has('sid-2')).toBe(true); // different client
expect(sessions.has('sid-3')).toBe(true); // different user
});
it('SESS-008: calls close() on matching sessions only', () => {
const match = makeSession({ userId: 1, clientId: 'client-a' });
const noMatch = makeSession({ userId: 1, clientId: 'client-b' });
sessions.set('sid-match', match);
sessions.set('sid-nomatch', noMatch);
revokeUserSessionsForClient(1, 'client-a');
expect(match.server.close).toHaveBeenCalledOnce();
expect(noMatch.server.close).not.toHaveBeenCalled();
});
it('SESS-009: does nothing when no sessions match userId+clientId', () => {
sessions.set('sid-1', makeSession({ userId: 1, clientId: 'other' }));
revokeUserSessionsForClient(1, 'client-a');
expect(sessions.has('sid-1')).toBe(true);
});
it('SESS-010: tolerates close() throwing for matched sessions', () => {
const s = makeSession({ userId: 1, clientId: 'c' });
(s.server.close as ReturnType<typeof vi.fn>).mockImplementation(() => { throw new Error('x'); });
sessions.set('sid-1', s);
expect(() => revokeUserSessionsForClient(1, 'c')).not.toThrow();
expect(sessions.has('sid-1')).toBe(false);
});
});
+128
View File
@@ -44,6 +44,13 @@ const { isAddonEnabledMock } = vi.hoisted(() => {
}); });
vi.mock('../../../src/services/adminService', () => ({ isAddonEnabled: isAddonEnabledMock })); vi.mock('../../../src/services/adminService', () => ({ isAddonEnabled: isAddonEnabledMock }));
const { mockGetTripSummary } = vi.hoisted(() => ({
mockGetTripSummary: vi.fn(),
}));
vi.mock('../../../src/services/tripService', () => ({
getTripSummary: mockGetTripSummary,
}));
import { createTables } from '../../../src/db/schema'; import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations'; import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db'; import { resetTestDb } from '../../helpers/test-db';
@@ -59,6 +66,30 @@ beforeEach(() => {
resetTestDb(testDb); resetTestDb(testDb);
broadcastMock.mockClear(); broadcastMock.mockClear();
isAddonEnabledMock.mockReturnValue(true); isAddonEnabledMock.mockReturnValue(true);
// Default mock: returns a trip-summary-shaped value from the real in-memory DB
// so that the trip title / existence match what tests insert, but budget/packing
// are arrays (as prompts.ts expects), not the object shape getTripSummary now returns.
mockGetTripSummary.mockImplementation((tripId: any) => {
const trip = testDb.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as any;
if (!trip) return null;
const members = testDb.prepare(`
SELECT u.id, u.username as name, u.email
FROM trip_members m JOIN users u ON u.id = m.user_id
WHERE m.trip_id = ?
`).all(tripId) as any[];
const budgetRows = testDb.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(tripId) as any[];
const packingRows = testDb.prepare('SELECT * FROM packing_items WHERE trip_id = ?').all(tripId) as any[];
return {
trip,
days: [],
members,
budget: budgetRows, // array shape expected by prompts.ts
packing: packingRows, // array shape expected by prompts.ts
reservations: [],
collabNotes: [],
};
});
}); });
afterAll(() => { afterAll(() => {
@@ -89,6 +120,15 @@ function listRegisteredPrompts(server: McpServer): string[] {
return Object.keys(prompts); return Object.keys(prompts);
} }
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
/** Return only the text of a prompt result, ignoring error shapes. */
async function invokePromptText(server: McpServer, name: string, args: Record<string, unknown>): Promise<string> {
return invokePrompt(server, name, args);
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// token_auth_notice // token_auth_notice
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -152,6 +192,40 @@ describe('Prompt: trip-summary', () => {
expect(err.message).not.toContain('access denied'); expect(err.message).not.toContain('access denied');
} }
}); });
it('returns "Trip not found." when getTripSummary returns null for accessible trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Ghost Trip' });
// Override mock to return null (covers lines 46-48 in prompts.ts)
mockGetTripSummary.mockReturnValueOnce(null);
const server = buildServer(user.id);
const text = await invokePromptText(server, 'trip-summary', { tripId: trip.id });
expect(text).toContain('Trip not found.');
});
it('handles null optional trip fields gracefully (covers || fallbacks)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: '' });
// Return summary with minimal trip fields (no title, no dates, no description)
mockGetTripSummary.mockReturnValueOnce({
trip: { id: trip.id, title: null, description: null, start_date: null, end_date: null, currency: null, user_id: user.id },
days: [],
members: [],
budget: [],
packing: [],
reservations: [],
collabNotes: [],
});
const server = buildServer(user.id);
const text = await invokePromptText(server, 'trip-summary', { tripId: trip.id });
expect(text).toContain('Untitled');
expect(text).toContain('?'); // start/end date fallback
expect(text).toContain('EUR'); // currency fallback
});
}); });
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -208,6 +282,21 @@ describe('Prompt: packing-list', () => {
// Items should be in checklist format // Items should be in checklist format
expect(text).toMatch(/\[[ x]\]/); expect(text).toMatch(/\[[ x]\]/);
}); });
it('uses tripId as title fallback when getTripSummary returns null (covers || {} branch)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Null Trip' });
createPackingItem(testDb, trip.id, { name: 'Toothbrush', category: 'Hygiene' });
// Null out the getTripSummary call inside packing-list (line 94: || {})
mockGetTripSummary.mockReturnValueOnce(null);
const server = buildServer(user.id);
const text = await invokePromptText(server, 'packing-list', { tripId: trip.id });
expect(text).toContain('Toothbrush');
// Falls back to 'Trip' literal since trip?.title is undefined (getTripSummary null → || {})
expect(text).toContain('Packing List: Trip');
});
}); });
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -273,4 +362,43 @@ describe('Prompt: budget-overview', () => {
expect(err.message).toContain('is not a function'); expect(err.message).toContain('is not a function');
} }
}); });
it('returns "Trip not found." when getTripSummary returns null for accessible trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Ghost Trip' });
// Override mock to return null (covers lines 116-118 in prompts.ts)
mockGetTripSummary.mockReturnValueOnce(null);
const server = buildServer(user.id);
const text = await invokePromptText(server, 'budget-overview', { tripId: trip.id });
expect(text).toContain('Trip not found.');
});
it('renders budget by category with correct totals and per-person calculation', async () => {
const { user } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Budget Trip' });
addTripMember(testDb, trip.id, member.id);
createBudgetItem(testDb, trip.id, { name: 'Flight', category: 'Transport', total_price: 200 });
createBudgetItem(testDb, trip.id, { name: 'Bus', category: 'Transport', total_price: 50 });
createBudgetItem(testDb, trip.id, { name: 'Hotel', category: 'Accommodation', total_price: 300 });
const server = buildServer(user.id);
const text = await invokePromptText(server, 'budget-overview', { tripId: trip.id });
expect(text).toContain('Budget Trip');
expect(text).toContain('Transport');
expect(text).toContain('Accommodation');
expect(text).toContain('550'); // Transport total
expect(text).toContain('300'); // Accommodation total
});
it('renders "No expenses recorded." when budget array is empty', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Empty Budget' });
const server = buildServer(user.id);
const text = await invokePromptText(server, 'budget-overview', { tripId: trip.id });
expect(text).toContain('No expenses recorded.');
});
}); });
@@ -0,0 +1,405 @@
/**
* Unit tests for collabService — COLLAB-SVC-001 to COLLAB-SVC-030.
* Covers votePoll edge cases, listMessages pagination, deleteMessage ownership,
* updateNote partial fields, fetchLinkPreview, avatarUrl, createMessage reply validation.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
// ── DB setup ─────────────────────────────────────────────────────────────────
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`
SELECT t.id FROM trips t
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)
`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
// Stub checkSsrf so fetchLinkPreview tests can control SSRF behaviour
const { mockCheckSsrf, mockCreatePinnedDispatcher } = vi.hoisted(() => ({
mockCheckSsrf: vi.fn(async () => ({ allowed: true, resolvedIp: '93.184.216.34' })),
mockCreatePinnedDispatcher: vi.fn(() => ({})),
}));
vi.mock('../../../src/utils/ssrfGuard', () => ({
checkSsrf: mockCheckSsrf,
createPinnedDispatcher: mockCreatePinnedDispatcher,
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip } from '../../helpers/factories';
import {
avatarUrl,
votePoll,
listMessages,
createMessage,
deleteMessage,
updateNote,
createNote,
createPoll,
closePoll,
fetchLinkPreview,
} from '../../../src/services/collabService';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
mockCheckSsrf.mockResolvedValue({ allowed: true, resolvedIp: '93.184.216.34' });
});
afterAll(() => {
testDb.close();
});
afterEach(() => {
vi.unstubAllGlobals();
mockCheckSsrf.mockReset();
mockCheckSsrf.mockResolvedValue({ allowed: true, resolvedIp: '93.184.216.34' });
});
// ── Helpers ───────────────────────────────────────────────────────────────────
function setup() {
const { user: user1 } = createUser(testDb);
const { user: user2 } = createUser(testDb);
const trip = createTrip(testDb, user1.id);
return { user1, user2, trip };
}
// ── avatarUrl ─────────────────────────────────────────────────────────────────
describe('avatarUrl', () => {
it('COLLAB-SVC-001: returns null when avatar is null', () => {
expect(avatarUrl({ avatar: null })).toBeNull();
});
it('COLLAB-SVC-002: returns upload path when avatar is set', () => {
expect(avatarUrl({ avatar: 'abc.jpg' })).toBe('/uploads/avatars/abc.jpg');
});
it('COLLAB-SVC-003: returns null when avatar is empty string', () => {
expect(avatarUrl({ avatar: '' })).toBeNull();
});
});
// ── votePoll ──────────────────────────────────────────────────────────────────
describe('votePoll', () => {
it('COLLAB-SVC-004: returns error "closed" when poll is closed', () => {
const { user1, trip } = setup();
const poll = createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] });
closePoll(trip.id, poll!.id);
const result = votePoll(trip.id, poll!.id, user1.id, 0);
expect(result.error).toBe('closed');
});
it('COLLAB-SVC-005: returns error "invalid_index" for negative index', () => {
const { user1, trip } = setup();
const poll = createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] });
const result = votePoll(trip.id, poll!.id, user1.id, -1);
expect(result.error).toBe('invalid_index');
});
it('COLLAB-SVC-006: returns error "invalid_index" for out-of-range index', () => {
const { user1, trip } = setup();
const poll = createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] });
const result = votePoll(trip.id, poll!.id, user1.id, 5);
expect(result.error).toBe('invalid_index');
});
it('COLLAB-SVC-007: returns error "not_found" for nonexistent poll', () => {
const { user1, trip } = setup();
const result = votePoll(trip.id, 9999, user1.id, 0);
expect(result.error).toBe('not_found');
});
it('COLLAB-SVC-008: successfully votes and returns poll with voters', () => {
const { user1, trip } = setup();
const poll = createPoll(trip.id, user1.id, { question: 'Q?', options: ['Yes', 'No'] });
const result = votePoll(trip.id, poll!.id, user1.id, 0);
expect(result.error).toBeUndefined();
expect(result.poll).toBeDefined();
expect(result.poll!.options[0].voters).toHaveLength(1);
});
it('COLLAB-SVC-009: toggles vote off when voted again on same option', () => {
const { user1, trip } = setup();
const poll = createPoll(trip.id, user1.id, { question: 'Q?', options: ['Yes', 'No'] });
votePoll(trip.id, poll!.id, user1.id, 0);
const result = votePoll(trip.id, poll!.id, user1.id, 0);
expect(result.poll!.options[0].voters).toHaveLength(0);
});
});
// ── listMessages with before cursor ──────────────────────────────────────────
describe('listMessages', () => {
it('COLLAB-SVC-010: returns all messages when no before cursor', () => {
const { user1, trip } = setup();
createMessage(trip.id, user1.id, 'Hello');
createMessage(trip.id, user1.id, 'World');
const msgs = listMessages(trip.id);
expect(msgs).toHaveLength(2);
});
it('COLLAB-SVC-011: paginates using before cursor (returns messages with id < before)', () => {
const { user1, trip } = setup();
const r1 = createMessage(trip.id, user1.id, 'First');
const r2 = createMessage(trip.id, user1.id, 'Second');
const r3 = createMessage(trip.id, user1.id, 'Third');
const id3 = r3.message!.id;
const msgs = listMessages(trip.id, id3);
expect(msgs.length).toBe(2);
const texts = msgs.map(m => m.text);
expect(texts).toContain('First');
expect(texts).toContain('Second');
expect(texts).not.toContain('Third');
});
it('COLLAB-SVC-012: returns messages in ascending order (reversed after DESC query)', () => {
const { user1, trip } = setup();
createMessage(trip.id, user1.id, 'A');
createMessage(trip.id, user1.id, 'B');
createMessage(trip.id, user1.id, 'C');
const msgs = listMessages(trip.id);
expect(msgs[0].text).toBe('A');
expect(msgs[2].text).toBe('C');
});
it('COLLAB-SVC-013: includes reactions grouped by emoji', () => {
const { user1, trip } = setup();
const r = createMessage(trip.id, user1.id, 'React me');
const msgId = r.message!.id;
testDb.prepare('INSERT INTO collab_message_reactions (message_id, user_id, emoji) VALUES (?, ?, ?)').run(msgId, user1.id, '👍');
const msgs = listMessages(trip.id);
expect(msgs[0].reactions).toBeDefined();
expect(msgs[0].reactions).toHaveLength(1);
expect(msgs[0].reactions[0].emoji).toBe('👍');
});
});
// ── createMessage with invalid replyTo ───────────────────────────────────────
describe('createMessage', () => {
it('COLLAB-SVC-014: returns error when replyTo message does not exist', () => {
const { user1, trip } = setup();
const result = createMessage(trip.id, user1.id, 'Reply to nothing', 9999);
expect(result.error).toBe('reply_not_found');
});
it('COLLAB-SVC-015: creates message with valid replyTo', () => {
const { user1, trip } = setup();
const r1 = createMessage(trip.id, user1.id, 'Original');
const r2 = createMessage(trip.id, user1.id, 'Reply', r1.message!.id);
expect(r2.error).toBeUndefined();
expect(r2.message!.reply_to).toBe(r1.message!.id);
});
});
// ── deleteMessage ownership check ─────────────────────────────────────────────
describe('deleteMessage', () => {
it('COLLAB-SVC-016: returns error "not_owner" when user does not own message', () => {
const { user1, user2, trip } = setup();
const r = createMessage(trip.id, user1.id, 'My message');
const result = deleteMessage(trip.id, r.message!.id, user2.id);
expect(result.error).toBe('not_owner');
});
it('COLLAB-SVC-017: returns error "not_found" for nonexistent message', () => {
const { user1, trip } = setup();
const result = deleteMessage(trip.id, 9999, user1.id);
expect(result.error).toBe('not_found');
});
it('COLLAB-SVC-018: marks message as deleted when owner deletes it', () => {
const { user1, trip } = setup();
const r = createMessage(trip.id, user1.id, 'Delete me');
const result = deleteMessage(trip.id, r.message!.id, user1.id);
expect(result.error).toBeUndefined();
const row = testDb.prepare('SELECT deleted FROM collab_messages WHERE id = ?').get(r.message!.id) as any;
expect(row.deleted).toBe(1);
});
});
// ── updateNote partial fields ─────────────────────────────────────────────────
describe('updateNote', () => {
it('COLLAB-SVC-019: updates only title when other fields are undefined', () => {
const { user1, trip } = setup();
const note = createNote(trip.id, user1.id, { title: 'Original', content: 'Some content', website: 'https://example.com' });
updateNote(trip.id, note.id, { title: 'Updated' });
const updated = testDb.prepare('SELECT * FROM collab_notes WHERE id = ?').get(note.id) as any;
expect(updated.title).toBe('Updated');
expect(updated.content).toBe('Some content'); // unchanged
expect(updated.website).toBe('https://example.com'); // unchanged
});
it('COLLAB-SVC-020: clears content when content is explicitly set to empty string', () => {
const { user1, trip } = setup();
const note = createNote(trip.id, user1.id, { title: 'T', content: 'Old content' });
updateNote(trip.id, note.id, { content: '' });
const updated = testDb.prepare('SELECT * FROM collab_notes WHERE id = ?').get(note.id) as any;
expect(updated.content).toBe('');
});
it('COLLAB-SVC-021: updates website when website is defined', () => {
const { user1, trip } = setup();
const note = createNote(trip.id, user1.id, { title: 'T' });
updateNote(trip.id, note.id, { website: 'https://new.example.com' });
const updated = testDb.prepare('SELECT * FROM collab_notes WHERE id = ?').get(note.id) as any;
expect(updated.website).toBe('https://new.example.com');
});
it('COLLAB-SVC-022: clears website when website is explicitly set to empty string', () => {
const { user1, trip } = setup();
const note = createNote(trip.id, user1.id, { title: 'T', website: 'https://old.com' });
updateNote(trip.id, note.id, { website: '' });
const updated = testDb.prepare('SELECT * FROM collab_notes WHERE id = ?').get(note.id) as any;
expect(updated.website).toBe('');
});
it('COLLAB-SVC-023: returns null when note does not exist', () => {
const { trip } = setup();
const result = updateNote(trip.id, 9999, { title: 'Ghost' });
expect(result).toBeNull();
});
it('COLLAB-SVC-024: updates pinned flag', () => {
const { user1, trip } = setup();
const note = createNote(trip.id, user1.id, { title: 'T', pinned: false });
updateNote(trip.id, note.id, { pinned: true });
const updated = testDb.prepare('SELECT * FROM collab_notes WHERE id = ?').get(note.id) as any;
expect(updated.pinned).toBe(1);
});
});
// ── fetchLinkPreview ──────────────────────────────────────────────────────────
describe('fetchLinkPreview', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('COLLAB-SVC-025: returns OG title and description from HTML', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
text: async () => `
<html>
<head>
<meta property="og:title" content="Test Title" />
<meta property="og:description" content="Test Description" />
<meta property="og:image" content="https://example.com/image.jpg" />
<meta property="og:site_name" content="Example" />
</head>
</html>
`,
}));
const result = await fetchLinkPreview('https://example.com/page');
expect(result.title).toBe('Test Title');
expect(result.description).toBe('Test Description');
expect(result.image).toBe('https://example.com/image.jpg');
expect(result.url).toBe('https://example.com/page');
});
it('COLLAB-SVC-026: falls back to <title> tag when no og:title', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
text: async () => `<html><head><title>Page Title</title></head></html>`,
}));
const result = await fetchLinkPreview('https://example.com/');
expect(result.title).toBe('Page Title');
});
it('COLLAB-SVC-027: returns fallback when fetch response is not ok', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
text: async () => '',
}));
const result = await fetchLinkPreview('https://example.com/bad');
expect(result.title).toBeNull();
expect(result.description).toBeNull();
expect(result.url).toBe('https://example.com/bad');
});
it('COLLAB-SVC-028: returns fallback when SSRF check blocks the URL', async () => {
mockCheckSsrf.mockResolvedValue({ allowed: false, error: 'SSRF blocked' });
const result = await fetchLinkPreview('https://169.254.169.254/');
expect(result.title).toBeNull();
});
it('COLLAB-SVC-029: returns fallback when fetch throws (network error)', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
const result = await fetchLinkPreview('https://example.com/net-error');
expect(result.title).toBeNull();
expect(result.url).toBe('https://example.com/net-error');
});
it('COLLAB-SVC-030: falls back to meta description tag when no og:description', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
text: async () => `
<html><head>
<meta name="description" content="Meta description here" />
</head></html>
`,
}));
const result = await fetchLinkPreview('https://example.com/meta');
expect(result.description).toBe('Meta description here');
});
});
@@ -0,0 +1,218 @@
/**
* Unit tests for memories/helpersService — MEM-HELPERS-001 to MEM-HELPERS-020.
* Covers mapDbError, getAlbumIdFromLink, pipeAsset error paths.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
// ── DB setup ─────────────────────────────────────────────────────────────────
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`
SELECT t.id FROM trips t
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)
`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
const { mockSafeFetch } = vi.hoisted(() => ({
mockSafeFetch: vi.fn(),
}));
vi.mock('../../../src/utils/ssrfGuard', () => {
class SsrfBlockedError extends Error {
constructor(msg: string) { super(msg); this.name = 'SsrfBlockedError'; }
}
return {
safeFetch: mockSafeFetch,
SsrfBlockedError,
checkSsrf: vi.fn(async () => ({ allowed: true, resolvedIp: '1.2.3.4' })),
};
});
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip } from '../../helpers/factories';
import { mapDbError, getAlbumIdFromLink, pipeAsset } from '../../../src/services/memories/helpersService';
import { SsrfBlockedError } from '../../../src/utils/ssrfGuard';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
mockSafeFetch.mockReset();
});
afterAll(() => {
testDb.close();
});
// ── mapDbError ────────────────────────────────────────────────────────────────
describe('mapDbError', () => {
it('MEM-HELPERS-001: returns 409 for unique constraint error', () => {
const err = new Error('UNIQUE constraint failed: users.email');
const result = mapDbError(err, 'fallback');
expect(result.success).toBe(false);
expect(result.error.status).toBe(409);
expect(result.error.message).toBe('Resource already exists');
});
it('MEM-HELPERS-002: returns 409 for generic constraint error', () => {
const err = new Error('constraint violation');
const result = mapDbError(err, 'fallback');
expect(result.success).toBe(false);
expect(result.error.status).toBe(409);
});
it('MEM-HELPERS-003: returns 500 with original message for non-constraint error', () => {
const err = new Error('Something went wrong');
const result = mapDbError(err, 'fallback');
expect(result.success).toBe(false);
expect(result.error.status).toBe(500);
expect(result.error.message).toBe('Something went wrong');
});
it('MEM-HELPERS-004: returns 500 for generic DB error', () => {
const err = new Error('disk I/O error');
const result = mapDbError(err, 'fallback');
expect(result.error.status).toBe(500);
});
});
// ── getAlbumIdFromLink ────────────────────────────────────────────────────────
describe('getAlbumIdFromLink', () => {
it('MEM-HELPERS-005: returns 404 when trip access is denied', () => {
const result = getAlbumIdFromLink('9999', 'link-1', 1);
expect(result.success).toBe(false);
expect(result.error.status).toBe(404);
});
it('MEM-HELPERS-006: returns 404 when album link is not found', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const result = getAlbumIdFromLink(String(trip.id), 'nonexistent-link', user.id);
expect(result.success).toBe(false);
expect(result.error.status).toBe(404);
expect(result.error.message).toBe('Album link not found');
});
it('MEM-HELPERS-007: returns album_id when link exists', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// Insert with auto-increment id (INTEGER PRIMARY KEY)
const ins = testDb.prepare(
'INSERT INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)'
).run(trip.id, user.id, 'immich', 'album-123', 'My Album');
const linkId = ins.lastInsertRowid;
const result = getAlbumIdFromLink(String(trip.id), String(linkId), user.id);
expect(result.success).toBe(true);
expect((result as any).data).toBe('album-123');
});
});
// ── pipeAsset ─────────────────────────────────────────────────────────────────
describe('pipeAsset', () => {
function mockResponse(overrides: Record<string, any> = {}) {
return {
status: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
end: vi.fn(),
json: vi.fn(),
headersSent: false,
...overrides,
} as any;
}
it('MEM-HELPERS-009: calls response.end() when resp.body is null', async () => {
mockSafeFetch.mockResolvedValue({
status: 200,
headers: { get: vi.fn(() => null) },
body: null,
});
const res = mockResponse();
await pipeAsset('https://example.com/asset', res);
expect(res.end).toHaveBeenCalled();
});
it('MEM-HELPERS-010: returns 400 when SsrfBlockedError is thrown', async () => {
mockSafeFetch.mockRejectedValue(new SsrfBlockedError('SSRF blocked'));
const res = mockResponse({ headersSent: false });
await pipeAsset('https://internal.example.com/asset', res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.any(String) }));
});
it('MEM-HELPERS-011: returns 500 for generic fetch error', async () => {
mockSafeFetch.mockRejectedValue(new Error('Network error'));
const res = mockResponse({ headersSent: false });
await pipeAsset('https://example.com/asset', res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({ error: 'Failed to fetch asset' });
});
it('MEM-HELPERS-012: calls response.end() when headersSent is true on error', async () => {
mockSafeFetch.mockRejectedValue(new Error('fail'));
const res = mockResponse({ headersSent: true });
await pipeAsset('https://example.com/asset', res);
expect(res.end).toHaveBeenCalled();
expect(res.json).not.toHaveBeenCalled();
});
it('MEM-HELPERS-013: sets content-type header when present in response', async () => {
mockSafeFetch.mockResolvedValue({
status: 200,
headers: {
get: (h: string) => {
if (h === 'content-type') return 'image/jpeg';
return null;
},
},
body: null,
});
const res = mockResponse();
await pipeAsset('https://example.com/img.jpg', res);
expect(res.set).toHaveBeenCalledWith('Content-Type', 'image/jpeg');
expect(res.end).toHaveBeenCalled();
});
});
@@ -0,0 +1,216 @@
/**
* Unit tests for memories/unifiedService — MEM-UNIFIED-001 to MEM-UNIFIED-010.
* Covers error paths: access denied, disabled provider, no providers enabled.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
// ── DB setup ─────────────────────────────────────────────────────────────────
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`
SELECT t.id FROM trips t
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)
`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
vi.mock('../../../src/services/notificationService', () => ({
send: vi.fn().mockResolvedValue(undefined),
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip } from '../../helpers/factories';
import {
listTripPhotos,
listTripAlbumLinks,
addTripPhotos,
setTripPhotoSharing,
removeTripPhoto,
createTripAlbumLink,
removeAlbumLink,
} from '../../../src/services/memories/unifiedService';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
// Ensure default providers are enabled (resetTestDb seeds them but doesn't reset enabled flag)
testDb.prepare('UPDATE photo_providers SET enabled = 1').run();
});
afterAll(() => {
testDb.close();
});
// ── listTripPhotos ────────────────────────────────────────────────────────────
describe('listTripPhotos', () => {
it('MEM-UNIFIED-001: returns 404 when user cannot access trip', () => {
const result = listTripPhotos('9999', 1);
expect(result.success).toBe(false);
expect((result as any).error.status).toBe(404);
});
it('MEM-UNIFIED-002: returns 400 when no photo providers are enabled', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// Disable all providers
testDb.prepare('UPDATE photo_providers SET enabled = 0').run();
const result = listTripPhotos(String(trip.id), user.id);
expect(result.success).toBe(false);
expect((result as any).error.status).toBe(400);
expect((result as any).error.message).toMatch(/no photo providers enabled/i);
});
});
// ── listTripAlbumLinks ────────────────────────────────────────────────────────
describe('listTripAlbumLinks', () => {
it('MEM-UNIFIED-003: returns 404 when user cannot access trip', () => {
const result = listTripAlbumLinks('9999', 1);
expect(result.success).toBe(false);
expect((result as any).error.status).toBe(404);
});
it('MEM-UNIFIED-004: returns 400 when no photo providers are enabled', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare('UPDATE photo_providers SET enabled = 0').run();
const result = listTripAlbumLinks(String(trip.id), user.id);
expect(result.success).toBe(false);
expect((result as any).error.status).toBe(400);
});
});
// ── addTripPhotos ─────────────────────────────────────────────────────────────
describe('addTripPhotos', () => {
it('MEM-UNIFIED-005: returns 404 when user cannot access trip', async () => {
const result = await addTripPhotos('9999', 1, false, [{ provider: 'immich', asset_ids: ['a1'] }], 'sid');
expect(result.success).toBe(false);
expect((result as any).error.status).toBe(404);
});
it('MEM-UNIFIED-006: returns 400 when provider is found but disabled (covers line 25)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// Insert a disabled provider
testDb.prepare(
'INSERT OR IGNORE INTO photo_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)'
).run('disabled-prov', 'Disabled', 'Disabled provider', 'Image', 0, 99);
const result = await addTripPhotos(
String(trip.id),
user.id,
false,
[{ provider: 'disabled-prov', asset_ids: ['asset-x'] }],
'sid',
);
expect(result.success).toBe(false);
expect((result as any).error.status).toBe(400);
expect((result as any).error.message).toMatch(/not enabled/i);
});
it('MEM-UNIFIED-007: returns 400 when provider is not found', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const result = await addTripPhotos(
String(trip.id),
user.id,
false,
[{ provider: 'nonexistent-provider', asset_ids: ['asset-x'] }],
'sid',
);
expect(result.success).toBe(false);
expect((result as any).error.status).toBe(400);
expect((result as any).error.message).toMatch(/not supported/i);
});
});
// ── setTripPhotoSharing ───────────────────────────────────────────────────────
describe('setTripPhotoSharing', () => {
it('MEM-UNIFIED-008: returns 404 when user cannot access trip', async () => {
const result = await setTripPhotoSharing('9999', 1, 'immich', 'asset-1', true);
expect(result.success).toBe(false);
expect((result as any).error.status).toBe(404);
});
});
// ── removeTripPhoto ───────────────────────────────────────────────────────────
describe('removeTripPhoto', () => {
it('MEM-UNIFIED-009: returns 404 when user cannot access trip', () => {
const result = removeTripPhoto('9999', 1, 'immich', 'asset-1');
expect(result.success).toBe(false);
expect((result as any).error.status).toBe(404);
});
});
// ── createTripAlbumLink ───────────────────────────────────────────────────────
describe('createTripAlbumLink', () => {
it('MEM-UNIFIED-010: returns 404 when user cannot access trip', () => {
const result = createTripAlbumLink('9999', 1, 'immich', 'album-1', 'My Album');
expect(result.success).toBe(false);
expect((result as any).error.status).toBe(404);
});
it('MEM-UNIFIED-011: returns 400 when provider is disabled', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare(
'INSERT OR IGNORE INTO photo_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)'
).run('disabled-prov2', 'Disabled2', 'desc', 'Image', 0, 100);
const result = createTripAlbumLink(String(trip.id), user.id, 'disabled-prov2', 'album-1', 'My Album');
expect(result.success).toBe(false);
expect((result as any).error.status).toBe(400);
});
});
// ── removeAlbumLink ───────────────────────────────────────────────────────────
describe('removeAlbumLink', () => {
it('MEM-UNIFIED-012: returns 404 when user cannot access trip', () => {
const result = removeAlbumLink('9999', '1', 1);
expect(result.success).toBe(false);
expect((result as any).error.status).toBe(404);
});
});
@@ -389,3 +389,74 @@ describe('findOrCreateUser', () => {
expect(token.used_count).toBe(1); expect(token.used_count).toBe(1);
}); });
}); });
// ── exchangeCodeForToken ──────────────────────────────────────────────────────
describe('exchangeCodeForToken', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('OIDC-SVC-030: sends correct POST body and returns token data', async () => {
const { exchangeCodeForToken } = await import('../../../src/services/oidcService');
const mockTokenData = { access_token: 'tok', token_type: 'Bearer' };
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => mockTokenData,
}));
const doc = { token_endpoint: 'https://oidc.example.com/token' } as any;
const result = await exchangeCodeForToken(doc, 'auth-code-123', 'https://app/callback', 'client-id', 'client-secret');
expect(result.access_token).toBe('tok');
expect(result._ok).toBe(true);
expect(result._status).toBe(200);
const fetchCall = (fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(fetchCall[0]).toBe('https://oidc.example.com/token');
expect(fetchCall[1].method).toBe('POST');
});
it('OIDC-SVC-031: reflects _ok=false when provider returns error status', async () => {
const { exchangeCodeForToken } = await import('../../../src/services/oidcService');
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
status: 400,
json: async () => ({ error: 'invalid_grant' }),
}));
const doc = { token_endpoint: 'https://oidc.example.com/token' } as any;
const result = await exchangeCodeForToken(doc, 'bad-code', 'https://app/callback', 'c', 's');
expect(result._ok).toBe(false);
expect(result._status).toBe(400);
});
});
// ── getUserInfo ───────────────────────────────────────────────────────────────
describe('getUserInfo', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('OIDC-SVC-032: fetches userinfo with Bearer token and returns parsed JSON', async () => {
const { getUserInfo } = await import('../../../src/services/oidcService');
const userInfoData = { sub: 'user-sub', email: 'user@example.com', name: 'User Name' };
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
json: async () => userInfoData,
}));
const result = await getUserInfo('https://oidc.example.com/userinfo', 'access-token-123');
expect(result.sub).toBe('user-sub');
expect(result.email).toBe('user@example.com');
const fetchCall = (fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(fetchCall[1].headers.Authorization).toBe('Bearer access-token-123');
});
});
+9 -5
View File
@@ -16,8 +16,12 @@ sonar.javascript.lcov.reportPaths=server/coverage/lcov.info,client/coverage/lcov
# Exclude test files from source analysis and exclude infrastructure/bootstrap files # Exclude test files from source analysis and exclude infrastructure/bootstrap files
sonar.exclusions=**/node_modules/**,**/dist/**,**/build/**,**/*.test.ts,**/*.test.tsx sonar.exclusions=**/node_modules/**,**/dist/**,**/build/**,**/*.test.ts,**/*.test.tsx
sonar.coverage.exclusions=\ sonar.coverage.exclusions=\
server/src/index.ts,\ server/src/index.ts,\
server/src/db/database.ts,\ server/src/db/database.ts,\
server/src/db/seeds.ts,\ server/src/db/seeds.ts,\
server/src/demo/**,\ server/src/demo/**,\
server/src/config.ts server/src/config.ts,\
server/src/db/migrations.ts,\
server/src/scheduler.ts,\
client/src/main.tsx,\
client/src/types.ts