test(client): expand frontend test suite to 69.1% coverage

Add and extend tests across 32 files (+10 595 lines) covering Admin
panels (AuditLog, Backup, DevNotifications, GitHub), Collab (Chat,
Notes, Panel, Polls), Planner (DayDetailPanel, DayPlanSidebar),
Settings (DisplaySettings, Integrations, MapSettings), Files
(FileManager, FilesPage), Map, Layout (DemoBanner,
InAppNotificationBell), shared pickers (CustomDateTimePicker,
CustomTimePicker), Vacay holidays, pages (Dashboard, Login), unit
stores (authStore, inAppNotificationStore), API (authUrl, client
integration), and i18n. Also updates sonar-project.properties and
MSW trip handlers to support the new cases.
This commit is contained in:
jubnl
2026-04-07 21:55:41 +02:00
parent 9390a2e9c6
commit fd48169219
32 changed files with 10595 additions and 15 deletions
@@ -0,0 +1,223 @@
// FE-ADMIN-AUDIT-001 to FE-ADMIN-AUDIT-010
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 { resetAllStores } from '../../../tests/helpers/store';
import AuditLogPanel from './AuditLogPanel';
const ENTRY_1 = {
id: 1,
created_at: '2025-06-01T10:30:00Z',
user_id: 5,
username: 'alice',
user_email: 'alice@example.com',
action: 'trip.create',
resource: '/trips/42',
details: { title: 'Test' },
ip: '127.0.0.1',
};
const ENTRY_2 = {
id: 2,
created_at: '2025-06-02T11:00:00Z',
user_id: 6,
username: 'bob',
user_email: 'bob@example.com',
action: 'trip.delete',
resource: '/trips/43',
details: null,
ip: '10.0.0.1',
};
beforeEach(() => {
resetAllStores();
});
afterEach(() => {
server.resetHandlers();
});
describe('AuditLogPanel', () => {
it('FE-ADMIN-AUDIT-001: loading state shown on mount', async () => {
server.use(
http.get('/api/admin/audit-log', async () => {
await new Promise(() => {}); // never resolves
return HttpResponse.json({ entries: [], total: 0 });
}),
);
render(<AuditLogPanel serverTimezone="UTC" />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
expect(document.querySelector('table')).not.toBeInTheDocument();
});
it('FE-ADMIN-AUDIT-002: empty state shown when no entries', async () => {
server.use(
http.get('/api/admin/audit-log', () =>
HttpResponse.json({ entries: [], total: 0 }),
),
);
render(<AuditLogPanel serverTimezone="UTC" />);
await screen.findByText('No audit entries yet.');
expect(document.querySelector('table')).not.toBeInTheDocument();
});
it('FE-ADMIN-AUDIT-003: table renders all columns with data', async () => {
server.use(
http.get('/api/admin/audit-log', () =>
HttpResponse.json({ entries: [ENTRY_1], total: 1 }),
),
);
render(<AuditLogPanel serverTimezone="UTC" />);
await screen.findByText('trip.create');
expect(screen.getByText('Time')).toBeInTheDocument();
expect(screen.getByText('User')).toBeInTheDocument();
expect(screen.getByText('Action')).toBeInTheDocument();
expect(screen.getByText('Resource')).toBeInTheDocument();
expect(screen.getByText('IP')).toBeInTheDocument();
expect(screen.getByText('Details')).toBeInTheDocument();
expect(screen.getByText('alice')).toBeInTheDocument();
expect(screen.getByText('/trips/42')).toBeInTheDocument();
expect(screen.getByText('127.0.0.1')).toBeInTheDocument();
expect(screen.getByText('{"title":"Test"}')).toBeInTheDocument();
});
it('FE-ADMIN-AUDIT-004: userLabel fallback chain', async () => {
const entries = [
{ ...ENTRY_1, id: 10, username: 'alice', user_email: null, user_id: 5, action: 'a.username' },
{ ...ENTRY_1, id: 11, username: null, user_email: 'bob@example.com', user_id: 6, action: 'a.email' },
{ ...ENTRY_1, id: 12, username: null, user_email: null, user_id: 7, action: 'a.id' },
{ ...ENTRY_1, id: 13, username: null, user_email: null, user_id: null, action: 'a.none' },
];
server.use(
http.get('/api/admin/audit-log', () =>
HttpResponse.json({ entries, total: 4 }),
),
);
render(<AuditLogPanel serverTimezone="UTC" />);
await screen.findByText('a.username');
expect(screen.getByText('alice')).toBeInTheDocument();
expect(screen.getByText('bob@example.com')).toBeInTheDocument();
expect(screen.getByText('#7')).toBeInTheDocument();
// '—' appears multiple times (null resource, null ip for some, null user) — just check it exists
expect(screen.getAllByText('—').length).toBeGreaterThan(0);
});
it('FE-ADMIN-AUDIT-005: dash shown for null resource, ip, and details', async () => {
const entry = {
...ENTRY_1,
id: 20,
action: 'a.nulls',
resource: null,
ip: null,
details: null,
};
const entryEmptyDetails = {
...ENTRY_1,
id: 21,
action: 'a.emptyobj',
resource: '/ok',
ip: '1.2.3.4',
details: {},
};
server.use(
http.get('/api/admin/audit-log', () =>
HttpResponse.json({ entries: [entry, entryEmptyDetails], total: 2 }),
),
);
render(<AuditLogPanel serverTimezone="UTC" />);
await screen.findByText('a.nulls');
// null resource, null ip, null details → three '—' for entry; empty obj details → another '—'
const dashes = screen.getAllByText('—');
expect(dashes.length).toBeGreaterThanOrEqual(4);
});
it('FE-ADMIN-AUDIT-006: showing count text reflects count and total', async () => {
server.use(
http.get('/api/admin/audit-log', () =>
HttpResponse.json({ entries: [ENTRY_1], total: 50 }),
),
);
render(<AuditLogPanel serverTimezone="UTC" />);
await screen.findByText('trip.create');
expect(screen.getByText('1 loaded · 50 total')).toBeInTheDocument();
});
it('FE-ADMIN-AUDIT-007: "Load more" appends entries', async () => {
let callCount = 0;
server.use(
http.get('/api/admin/audit-log', () => {
callCount++;
if (callCount === 1) {
return HttpResponse.json({ entries: [ENTRY_1], total: 2 });
}
return HttpResponse.json({ entries: [ENTRY_2], total: 2 });
}),
);
const user = userEvent.setup();
render(<AuditLogPanel serverTimezone="UTC" />);
await screen.findByText('trip.create');
const loadMoreBtn = screen.getByText('Load more');
expect(loadMoreBtn).toBeInTheDocument();
await user.click(loadMoreBtn);
await screen.findByText('trip.delete');
expect(screen.getByText('trip.create')).toBeInTheDocument();
expect(screen.queryByText('Load more')).not.toBeInTheDocument();
});
it('FE-ADMIN-AUDIT-008: "Load more" hidden when all entries loaded', async () => {
server.use(
http.get('/api/admin/audit-log', () =>
HttpResponse.json({ entries: [ENTRY_1, ENTRY_2], total: 2 }),
),
);
render(<AuditLogPanel serverTimezone="UTC" />);
await screen.findByText('trip.create');
expect(screen.queryByText('Load more')).not.toBeInTheDocument();
});
it('FE-ADMIN-AUDIT-009: Refresh resets list to page 1', async () => {
const PAGE1_ENTRY = { ...ENTRY_1, id: 100, action: 'phase1.action' };
const PAGE2_ENTRY = { ...ENTRY_2, id: 101, action: 'phase2.action' };
const REFRESH_ENTRY = { ...ENTRY_2, id: 102, action: 'phase3.refresh' };
let callCount = 0;
server.use(
http.get('/api/admin/audit-log', () => {
callCount++;
if (callCount === 1) {
return HttpResponse.json({ entries: [PAGE1_ENTRY], total: 2 });
}
if (callCount === 2) {
return HttpResponse.json({ entries: [PAGE2_ENTRY], total: 2 });
}
return HttpResponse.json({ entries: [REFRESH_ENTRY], total: 1 });
}),
);
const user = userEvent.setup();
render(<AuditLogPanel serverTimezone="UTC" />);
// Initial load: PAGE1_ENTRY visible, load more
await screen.findByText('phase1.action');
const loadMoreBtn = screen.getByText('Load more');
await user.click(loadMoreBtn);
await screen.findByText('phase2.action');
// Now refresh
const refreshBtn = screen.getByText('Refresh');
await user.click(refreshBtn);
// After refresh, only REFRESH_ENTRY should be visible
await screen.findByText('phase3.refresh');
await waitFor(() => expect(screen.queryByText('phase1.action')).not.toBeInTheDocument());
expect(screen.queryByText('phase2.action')).not.toBeInTheDocument();
});
it('FE-ADMIN-AUDIT-010: Refresh button is disabled while loading', async () => {
server.use(
http.get('/api/admin/audit-log', async () => {
await new Promise(() => {}); // never resolves
return HttpResponse.json({ entries: [], total: 0 });
}),
);
render(<AuditLogPanel serverTimezone="UTC" />);
const refreshBtn = screen.getByText('Refresh');
expect(refreshBtn.closest('button')).toBeDisabled();
});
});
@@ -0,0 +1,313 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor, within, fireEvent } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { useSettingsStore } from '../../store/settingsStore'
import { server } from '../../../tests/helpers/msw/server'
import { http, HttpResponse } from 'msw'
import BackupPanel from './BackupPanel'
import { ToastContainer } from '../shared/Toast'
const manualBackup = {
filename: 'backup-2025-01-15.zip',
created_at: '2025-01-15T10:00:00Z',
size: 2048000,
}
const autoBackup = {
filename: 'auto-backup-2025-02-01.zip',
created_at: '2025-02-01T02:00:00Z',
size: 1024000,
}
function defaultBackupHandlers() {
return [
http.get('/api/backup/list', () => HttpResponse.json({ backups: [manualBackup] })),
http.get('/api/backup/auto-settings', () =>
HttpResponse.json({
settings: { enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
timezone: 'UTC',
}),
),
]
}
function getToggleButton() {
// The enable toggle is a <button> inside a <label> that contains "Enable auto-backup"
const label = screen.getByText('Enable auto-backup').closest('label') as HTMLElement
return label.querySelector('button') as HTMLElement
}
describe('BackupPanel', () => {
beforeEach(() => {
resetAllStores()
seedStore(useSettingsStore, { settings: { time_format: '24h' } } as any)
vi.spyOn(window, 'confirm').mockReturnValue(true)
server.use(...defaultBackupHandlers())
})
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
server.resetHandlers()
})
// BKP-001: Loading state
it('FE-ADMIN-BKP-001: shows loading spinner while fetching backups', async () => {
server.use(
http.get('/api/backup/list', async () => {
await new Promise(resolve => setTimeout(resolve, 300))
return HttpResponse.json({ backups: [] })
}),
)
render(<BackupPanel />)
expect(document.querySelector('.animate-spin')).toBeInTheDocument()
})
// BKP-002: Empty state
it('FE-ADMIN-BKP-002: shows empty state when no backups exist', async () => {
server.use(
http.get('/api/backup/list', () => HttpResponse.json({ backups: [] })),
)
render(<BackupPanel />)
await waitFor(() => {
expect(screen.getByText('No backups yet')).toBeInTheDocument()
})
expect(screen.getByText('Create first backup')).toBeInTheDocument()
})
// BKP-003: Backup list renders filename, size, and date
it('FE-ADMIN-BKP-003: renders filename, formatted size, and date for a backup', async () => {
render(<BackupPanel />)
await waitFor(() => {
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
})
expect(screen.getByText('2.0 MB')).toBeInTheDocument()
})
// BKP-004: Auto-backup badge shown for auto-backup filenames
it('FE-ADMIN-BKP-004: shows Auto badge for auto-backup filenames', async () => {
server.use(
http.get('/api/backup/list', () => HttpResponse.json({ backups: [autoBackup] })),
)
render(<BackupPanel />)
await waitFor(() => {
expect(screen.getByText('auto-backup-2025-02-01.zip')).toBeInTheDocument()
})
expect(screen.getByText('Auto')).toBeInTheDocument()
})
// BKP-005: Create backup success
it('FE-ADMIN-BKP-005: creates backup and shows success toast', async () => {
const user = userEvent.setup()
server.use(
http.post('/api/backup/create', () => HttpResponse.json({ success: true })),
http.get('/api/backup/list', () => HttpResponse.json({ backups: [manualBackup] })),
)
render(<><ToastContainer /><BackupPanel /></>)
await waitFor(() => {
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
})
await user.click(screen.getByTitle('Create Backup'))
await waitFor(() => {
expect(screen.getByText('Backup created successfully')).toBeInTheDocument()
})
})
// BKP-006: Restore opens confirmation modal
it('FE-ADMIN-BKP-006: clicking Restore opens confirmation modal', async () => {
const user = userEvent.setup()
render(<BackupPanel />)
await waitFor(() => {
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
})
await user.click(screen.getAllByText('Restore')[0])
await waitFor(() => {
expect(screen.getByText('Restore Backup?')).toBeInTheDocument()
})
expect(screen.getAllByText('backup-2025-01-15.zip').length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('Yes, restore')).toBeInTheDocument()
expect(screen.getByText('Cancel')).toBeInTheDocument()
})
// BKP-007: Cancel dismisses modal without calling restore API
it('FE-ADMIN-BKP-007: cancel dismisses the restore modal without calling the API', async () => {
const user = userEvent.setup()
let restoreCalled = false
server.use(
http.post('/api/backup/restore/:filename', () => {
restoreCalled = true
return HttpResponse.json({ success: true })
}),
)
render(<BackupPanel />)
await waitFor(() => {
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
})
await user.click(screen.getAllByText('Restore')[0])
await waitFor(() => {
expect(screen.getByText('Restore Backup?')).toBeInTheDocument()
})
await user.click(screen.getByText('Cancel'))
await waitFor(() => {
expect(screen.queryByText('Restore Backup?')).not.toBeInTheDocument()
})
expect(restoreCalled).toBe(false)
})
// BKP-008: Backdrop click dismisses modal
it('FE-ADMIN-BKP-008: clicking the backdrop dismisses the restore modal', async () => {
const user = userEvent.setup()
render(<BackupPanel />)
await waitFor(() => {
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
})
await user.click(screen.getAllByText('Restore')[0])
await waitFor(() => {
expect(screen.getByText('Restore Backup?')).toBeInTheDocument()
})
// Click the backdrop overlay (the fixed-position div)
const backdrop = document.querySelector('[style*="position: fixed"]') as HTMLElement
expect(backdrop).toBeTruthy()
fireEvent.click(backdrop!)
await waitFor(() => {
expect(screen.queryByText('Restore Backup?')).not.toBeInTheDocument()
})
})
// BKP-009: Successful restore calls API and reloads after 1500ms
it('FE-ADMIN-BKP-009: successful restore shows toast and reloads after 1500ms', async () => {
const user = userEvent.setup()
server.use(
http.post('/api/backup/restore/:filename', () => HttpResponse.json({ success: true })),
)
render(<><ToastContainer /><BackupPanel /></>)
await waitFor(() => {
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
})
// Stub reload AFTER initial data load so we don't corrupt window.location during setup
const reloadMock = vi.fn()
vi.stubGlobal('location', { ...window.location, reload: reloadMock })
await user.click(screen.getAllByText('Restore')[0])
await waitFor(() => expect(screen.getByText('Restore Backup?')).toBeInTheDocument())
await user.click(screen.getByText('Yes, restore'))
await waitFor(() => expect(screen.getByText('Backup restored. Page will reload…')).toBeInTheDocument())
// Wait for the 1500ms reload timer to fire
await new Promise(resolve => setTimeout(resolve, 1600))
expect(reloadMock).toHaveBeenCalled()
vi.unstubAllGlobals()
}, 20000)
// BKP-010: Delete backup with confirm dialog
it('FE-ADMIN-BKP-010: deletes backup after confirm and shows success toast', async () => {
const user = userEvent.setup()
server.use(
http.delete('/api/backup/:filename', () => HttpResponse.json({ success: true })),
)
render(<><ToastContainer /><BackupPanel /></>)
await waitFor(() => {
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
})
const trashBtn = Array.from(document.querySelectorAll('button')).find(
b => b.querySelector('svg.lucide-trash2'),
) as HTMLElement
expect(trashBtn).toBeTruthy()
await user.click(trashBtn!)
await waitFor(() => {
expect(screen.getByText('Backup deleted')).toBeInTheDocument()
})
await waitFor(() => {
expect(screen.queryByText('backup-2025-01-15.zip')).not.toBeInTheDocument()
})
})
// BKP-011: Auto-backup enable toggle shows interval controls
it('FE-ADMIN-BKP-011: enabling auto-backup shows interval controls', async () => {
const user = userEvent.setup()
render(<BackupPanel />)
await waitFor(() => {
expect(screen.getByText('Enable auto-backup')).toBeInTheDocument()
})
expect(screen.queryByText('Hourly')).not.toBeInTheDocument()
await user.click(getToggleButton())
await waitFor(() => {
expect(screen.getByText('Hourly')).toBeInTheDocument()
expect(screen.getByText('Daily')).toBeInTheDocument()
expect(screen.getByText('Weekly')).toBeInTheDocument()
expect(screen.getByText('Monthly')).toBeInTheDocument()
})
})
// BKP-012: Weekly interval shows day-of-week picker
it('FE-ADMIN-BKP-012: weekly interval shows day-of-week picker', async () => {
const user = userEvent.setup()
server.use(
http.get('/api/backup/auto-settings', () =>
HttpResponse.json({
settings: { enabled: true, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
timezone: 'UTC',
}),
),
)
render(<BackupPanel />)
await waitFor(() => {
expect(screen.getByText('Weekly')).toBeInTheDocument()
})
expect(screen.queryByText('Sun')).not.toBeInTheDocument()
await user.click(screen.getByText('Weekly'))
await waitFor(() => {
expect(screen.getByText('Sun')).toBeInTheDocument()
expect(screen.getByText('Mon')).toBeInTheDocument()
expect(screen.getByText('Sat')).toBeInTheDocument()
})
expect(screen.queryByText('Day of month')).not.toBeInTheDocument()
})
// BKP-013: Save auto-settings calls API and shows toast
it('FE-ADMIN-BKP-013: saving auto-settings calls API and shows success toast', async () => {
const user = userEvent.setup()
server.use(
http.get('/api/backup/auto-settings', () =>
HttpResponse.json({
settings: { enabled: true, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
timezone: 'UTC',
}),
),
http.put('/api/backup/auto-settings', () =>
HttpResponse.json({
settings: { enabled: true, interval: 'weekly', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
}),
),
)
render(<><ToastContainer /><BackupPanel /></>)
await waitFor(() => {
expect(screen.getByText('Weekly')).toBeInTheDocument()
})
await user.click(screen.getByText('Weekly'))
await waitFor(() => {
const saveBtn = screen.getByRole('button', { name: /^save$/i })
expect(saveBtn).not.toBeDisabled()
})
await user.click(screen.getByRole('button', { name: /^save$/i }))
await waitFor(() => {
expect(screen.getByText('Auto-backup settings saved')).toBeInTheDocument()
})
})
// BKP-014: Save button disabled until settings changed
it('FE-ADMIN-BKP-014: save button is disabled until settings are changed', async () => {
const user = userEvent.setup()
render(<BackupPanel />)
await waitFor(() => {
expect(screen.getByText('Enable auto-backup')).toBeInTheDocument()
})
const saveBtn = screen.getByRole('button', { name: /^save$/i })
expect(saveBtn).toBeDisabled()
await user.click(getToggleButton())
await waitFor(() => {
expect(screen.getByRole('button', { name: /^save$/i })).not.toBeDisabled()
})
})
})
@@ -0,0 +1,160 @@
// FE-ADMIN-DEVNOTIF-001 to FE-ADMIN-DEVNOTIF-010
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 { buildUser } from '../../../tests/helpers/factories';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
import { ToastContainer } from '../shared/Toast';
import DevNotificationsPanel from './DevNotificationsPanel';
const ADMIN_USER = buildUser({ id: 1, username: 'testadmin', role: 'admin' });
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { user: ADMIN_USER, isAuthenticated: true });
});
afterEach(() => {
server.resetHandlers();
});
describe('DevNotificationsPanel', () => {
it('FE-ADMIN-DEVNOTIF-001: "DEV ONLY" badge is always visible', () => {
render(<><ToastContainer /><DevNotificationsPanel /></>);
expect(screen.getByText('DEV ONLY')).toBeInTheDocument();
});
it('FE-ADMIN-DEVNOTIF-002: four section titles render after data loads', async () => {
render(<><ToastContainer /><DevNotificationsPanel /></>);
// Wait for async data to populate conditional sections
await screen.findByText('Trip-Scoped Events');
await screen.findByText('User-Scoped Events');
expect(screen.getByText('Type Testing')).toBeInTheDocument();
expect(screen.getByText('Admin-Scoped Events')).toBeInTheDocument();
});
it('FE-ADMIN-DEVNOTIF-003: trip selector populated from API', async () => {
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Trip-Scoped Events');
const [tripSelect] = screen.getAllByRole('combobox');
const options = Array.from(tripSelect.querySelectorAll('option'));
const labels = options.map(o => o.textContent);
expect(labels).toContain('Paris Adventure');
expect(labels).toContain('Tokyo Trip');
});
it('FE-ADMIN-DEVNOTIF-004: user selector populated from API', async () => {
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('User-Scoped Events');
const selects = screen.getAllByRole('combobox');
// Second combobox is the user selector (first is trip selector)
const userSelect = selects[1];
const options = Array.from(userSelect.querySelectorAll('option'));
const labels = options.map(o => o.textContent ?? '');
expect(labels.some(l => l.includes('admin'))).toBe(true);
expect(labels.some(l => l.includes('alice'))).toBe(true);
});
it('FE-ADMIN-DEVNOTIF-005: clicking "Simple → Me" fires sendTestNotification with correct payload', async () => {
let capturedBody: Record<string, unknown> | undefined;
server.use(
http.post('/api/admin/dev/test-notification', async ({ request }) => {
capturedBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ ok: true });
}),
);
const user = userEvent.setup();
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Type Testing');
await user.click(screen.getByText('Simple → Me').closest('button')!);
await waitFor(() => expect(capturedBody).toBeDefined());
expect(capturedBody).toMatchObject({
event: 'test_simple',
scope: 'user',
targetId: ADMIN_USER.id,
});
});
it('FE-ADMIN-DEVNOTIF-006: success toast shown after fire', async () => {
server.use(
http.post('/api/admin/dev/test-notification', () =>
HttpResponse.json({ ok: true }),
),
);
const user = userEvent.setup();
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Type Testing');
await user.click(screen.getByText('Simple → Me').closest('button')!);
await screen.findByText('Sent: simple-me');
});
it('FE-ADMIN-DEVNOTIF-007: all buttons disabled while a send is in-flight', async () => {
server.use(
http.post('/api/admin/dev/test-notification', async () => {
await new Promise(() => {}); // never resolves — simulates in-flight
return HttpResponse.json({ ok: true });
}),
);
const user = userEvent.setup();
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Type Testing');
// Fire the click but do not await — handler never resolves so sending stays true
void user.click(screen.getByText('Simple → Me').closest('button')!);
await waitFor(() => {
const buttons = screen.getAllByRole('button');
buttons.forEach(btn => expect(btn).toBeDisabled());
});
});
it('FE-ADMIN-DEVNOTIF-008: error toast shown on API failure', async () => {
server.use(
http.post('/api/admin/dev/test-notification', () =>
HttpResponse.json({ message: 'Server error' }, { status: 500 }),
),
);
const user = userEvent.setup();
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Type Testing');
await user.click(screen.getByText('Simple → Me').closest('button')!);
await screen.findByText(/failed|error/i);
});
it('FE-ADMIN-DEVNOTIF-009: changing trip selector updates payload targetId', async () => {
let capturedBody: Record<string, unknown> | undefined;
server.use(
http.post('/api/admin/dev/test-notification', async ({ request }) => {
capturedBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ ok: true });
}),
);
const user = userEvent.setup();
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Trip-Scoped Events');
const [tripSelect] = screen.getAllByRole('combobox');
const tokyoOption = Array.from(tripSelect.querySelectorAll('option')).find(
o => o.textContent === 'Tokyo Trip',
)!;
const tokyoId = Number(tokyoOption.value);
await user.selectOptions(tripSelect, 'Tokyo Trip');
await user.click(screen.getByText('booking_change').closest('button')!);
await waitFor(() => expect(capturedBody).toBeDefined());
expect(capturedBody!.targetId).toBe(tokyoId);
});
it('FE-ADMIN-DEVNOTIF-010: Trip-Scoped section absent when no trips', async () => {
server.use(
http.get('/api/trips', () => HttpResponse.json({ trips: [] })),
);
render(<><ToastContainer /><DevNotificationsPanel /></>);
// Wait for user data to confirm async effects have settled
await screen.findByText('User-Scoped Events');
expect(screen.queryByText('Trip-Scoped Events')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,336 @@
// FE-ADMIN-GH-001 to FE-ADMIN-GH-016
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { resetAllStores } from '../../../tests/helpers/store';
import GitHubPanel from './GitHubPanel';
function buildRelease(overrides = {}) {
const id = Math.random();
return {
id,
tag_name: 'v1.0.0',
name: 'Initial Release',
body: '## Changes\n- Fixed bug\n- **Bold improvement**\n- `code snippet`',
published_at: '2025-01-15T12:00:00Z',
created_at: '2025-01-15T12:00:00Z',
prerelease: false,
author: { login: 'mauriceboe' },
...overrides,
};
}
const PAGE_1 = Array.from({ length: 10 }, (_, i) =>
buildRelease({ id: i + 1, tag_name: `v1.${i}.0` }),
);
const PAGE_2 = Array.from({ length: 5 }, (_, i) =>
buildRelease({ id: 100 + i, tag_name: `v0.${i}.0` }),
);
beforeEach(() => {
resetAllStores();
server.use(
http.get('/api/admin/github-releases', () => HttpResponse.json([])),
);
});
afterEach(() => {
server.resetHandlers();
});
describe('GitHubPanel', () => {
it('FE-ADMIN-GH-001: support link cards always render', async () => {
render(<GitHubPanel />);
await waitFor(() =>
expect(screen.queryByRole('status')).not.toBeInTheDocument(),
);
expect(screen.getByText('Ko-fi')).toBeInTheDocument();
expect(screen.getByText('Buy Me a Coffee')).toBeInTheDocument();
expect(screen.getByText('Discord')).toBeInTheDocument();
expect(screen.getByText('Report a Bug')).toBeInTheDocument();
expect(screen.getByText('Feature Request')).toBeInTheDocument();
expect(screen.getByText('Wiki')).toBeInTheDocument();
});
it('FE-ADMIN-GH-002: all support links have correct href and target=_blank', async () => {
render(<GitHubPanel />);
await waitFor(() => expect(screen.queryByText('Loading...')).not.toBeInTheDocument());
const kofi = screen.getByText('Ko-fi').closest('a')!;
expect(kofi).toHaveAttribute('href', 'https://ko-fi.com/mauriceboe');
expect(kofi).toHaveAttribute('target', '_blank');
expect(kofi).toHaveAttribute('rel', 'noopener noreferrer');
const bmc = screen.getByText('Buy Me a Coffee').closest('a')!;
expect(bmc).toHaveAttribute('href', 'https://buymeacoffee.com/mauriceboe');
expect(bmc).toHaveAttribute('target', '_blank');
expect(bmc).toHaveAttribute('rel', 'noopener noreferrer');
const discord = screen.getByText('Discord').closest('a')!;
expect(discord).toHaveAttribute('href', 'https://discord.gg/nSdKaXgN');
expect(discord).toHaveAttribute('target', '_blank');
expect(discord).toHaveAttribute('rel', 'noopener noreferrer');
});
it('FE-ADMIN-GH-003: loading spinner shown while fetching releases', () => {
server.use(
http.get('/api/admin/github-releases', async () => {
await new Promise(() => {}); // never resolves
return HttpResponse.json([]);
}),
);
render(<GitHubPanel />);
// The Loader2 spinner is rendered while loading=true
const spinner = document.querySelector('.animate-spin');
expect(spinner).toBeInTheDocument();
});
it('FE-ADMIN-GH-004: error state shown on API failure', async () => {
server.use(
http.get('/api/admin/github-releases', () =>
HttpResponse.json({ message: 'Internal Server Error' }, { status: 500 }),
),
);
render(<GitHubPanel />);
await screen.findByText('Failed to load releases');
// Timeline should not be rendered
expect(screen.queryByText('Release History')).not.toBeInTheDocument();
});
it('FE-ADMIN-GH-005: releases render in timeline', async () => {
const r1 = buildRelease({ id: 1, tag_name: 'v1.0.0', author: { login: 'mauriceboe' } });
const r2 = buildRelease({ id: 2, tag_name: 'v1.1.0', author: { login: 'mauriceboe' } });
server.use(
http.get('/api/admin/github-releases', () => HttpResponse.json([r1, r2])),
);
render(<GitHubPanel />);
await screen.findByText('v1.0.0');
expect(screen.getByText('v1.1.0')).toBeInTheDocument();
// Author label
const authorLabels = screen.getAllByText(/mauriceboe/);
expect(authorLabels.length).toBeGreaterThan(0);
// Some date should be visible (non-empty)
const dateEls = document.querySelectorAll('[class*="text-"]');
const dateTexts = Array.from(dateEls).map(el => el.textContent).filter(t => t && t.match(/\d{4}/));
expect(dateTexts.length).toBeGreaterThan(0);
});
it('FE-ADMIN-GH-006: latest badge shown only on first release', async () => {
const r1 = buildRelease({ id: 1, tag_name: 'v2.0.0' });
const r2 = buildRelease({ id: 2, tag_name: 'v1.9.0' });
server.use(
http.get('/api/admin/github-releases', () => HttpResponse.json([r1, r2])),
);
render(<GitHubPanel />);
await screen.findByText('v2.0.0');
const latestBadges = screen.getAllByText('Latest');
expect(latestBadges).toHaveLength(1);
});
it('FE-ADMIN-GH-007: prerelease badge shown', async () => {
const r = buildRelease({ id: 10, tag_name: 'v3.0.0-beta.1', prerelease: true });
server.use(
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
);
render(<GitHubPanel />);
await screen.findByText('v3.0.0-beta.1');
expect(screen.getByText('Pre-release')).toBeInTheDocument();
});
it('FE-ADMIN-GH-008: expand/collapse release notes', async () => {
const r = buildRelease({
id: 20,
tag_name: 'v1.5.0',
body: '- Fixed bug\n- Another fix',
});
server.use(
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
);
const user = userEvent.setup();
render(<GitHubPanel />);
await screen.findByText('v1.5.0');
const showBtn = screen.getByText('Show details');
expect(showBtn).toBeInTheDocument();
// Body not visible yet
expect(screen.queryByText('Fixed bug')).not.toBeInTheDocument();
// Expand
await user.click(showBtn);
await screen.findByText('Fixed bug');
expect(screen.getByText('Hide details')).toBeInTheDocument();
// Collapse
await user.click(screen.getByText('Hide details'));
await waitFor(() =>
expect(screen.queryByText('Fixed bug')).not.toBeInTheDocument(),
);
expect(screen.getByText('Show details')).toBeInTheDocument();
});
it('FE-ADMIN-GH-009: release body renders markdown: lists, bold, code', async () => {
const r = buildRelease({
id: 30,
tag_name: 'v1.6.0',
body: '- list item\n- **bold text**\n- `inline code`',
});
server.use(
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
);
const user = userEvent.setup();
render(<GitHubPanel />);
await screen.findByText('v1.6.0');
await user.click(screen.getByText('Show details'));
await screen.findByText('list item');
// list item is inside a <li>
const listItem = screen.getByText('list item');
expect(listItem.closest('li')).toBeInTheDocument();
// Bold text rendered as <strong>
const container = document.querySelector('.mt-2.p-3.rounded-lg')!;
expect(container.querySelector('strong')).toBeInTheDocument();
expect(container.querySelector('strong')!.textContent).toBe('bold text');
// Code rendered as <code>
expect(container.querySelector('code')).toBeInTheDocument();
expect(container.querySelector('code')!.textContent).toBe('inline code');
});
it('FE-ADMIN-GH-010: "Load more" button visible when full page returned', async () => {
server.use(
http.get('/api/admin/github-releases', () => HttpResponse.json(PAGE_1)),
);
render(<GitHubPanel />);
await screen.findByText(`v1.0.0`);
expect(screen.getByText('Load more')).toBeInTheDocument();
});
it('FE-ADMIN-GH-011: "Load more" hidden when partial page returned', async () => {
server.use(
http.get('/api/admin/github-releases', () => HttpResponse.json(PAGE_2)),
);
render(<GitHubPanel />);
await screen.findByText('v0.0.0');
expect(screen.queryByText('Load more')).not.toBeInTheDocument();
});
it('FE-ADMIN-GH-013: release body renders plain paragraph text', async () => {
const r = buildRelease({
id: 40,
tag_name: 'v1.7.0',
body: 'This is a plain paragraph without any markdown syntax.',
});
server.use(
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
);
const user = userEvent.setup();
render(<GitHubPanel />);
await screen.findByText('v1.7.0');
await user.click(screen.getByText('Show details'));
await screen.findByText('This is a plain paragraph without any markdown syntax.');
});
it('FE-ADMIN-GH-014: markdown link with safe href renders as anchor', async () => {
const r = buildRelease({
id: 41,
tag_name: 'v1.8.0',
body: '- [click here](https://example.com)',
});
server.use(
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
);
const user = userEvent.setup();
render(<GitHubPanel />);
await screen.findByText('v1.8.0');
await user.click(screen.getByText('Show details'));
const link = await screen.findByText('click here');
expect(link.closest('a') || link.tagName.toLowerCase() === 'a' ? link : null).not.toBeNull();
});
it('FE-ADMIN-GH-015: javascript: link is sanitized to #', async () => {
const r = buildRelease({
id: 42,
tag_name: 'v1.9.0',
body: '- [evil](javascript:alert(1))',
});
server.use(
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
);
const user = userEvent.setup();
render(<GitHubPanel />);
await screen.findByText('v1.9.0');
await user.click(screen.getByText('Show details'));
const link = await screen.findByText('evil');
const anchor = link.closest('a') ?? link;
// The unsafe href is replaced with '#'
expect(anchor).toHaveAttribute('href', '#');
});
it('FE-ADMIN-GH-016: support card hover effects fire without error', async () => {
render(<GitHubPanel />);
await waitFor(() => expect(screen.queryByText('Loading...')).not.toBeInTheDocument());
const kofiLink = screen.getByText('Ko-fi').closest('a')!;
fireEvent.mouseEnter(kofiLink);
fireEvent.mouseLeave(kofiLink);
const discordLink = screen.getByText('Discord').closest('a')!;
fireEvent.mouseEnter(discordLink);
fireEvent.mouseLeave(discordLink);
const bugLink = screen.getByText('Report a Bug').closest('a')!;
fireEvent.mouseEnter(bugLink);
fireEvent.mouseLeave(bugLink);
const featureLink = screen.getByText('Feature Request').closest('a')!;
fireEvent.mouseEnter(featureLink);
fireEvent.mouseLeave(featureLink);
const wikiLink = screen.getByText('Wiki').closest('a')!;
fireEvent.mouseEnter(wikiLink);
fireEvent.mouseLeave(wikiLink);
const bmcLink = screen.getByText('Buy Me a Coffee').closest('a')!;
fireEvent.mouseEnter(bmcLink);
fireEvent.mouseLeave(bmcLink);
// All links still visible
expect(screen.getByText('Ko-fi')).toBeInTheDocument();
});
it('FE-ADMIN-GH-012: clicking "Load more" appends next page', async () => {
server.use(
http.get('/api/admin/github-releases', ({ request }) => {
const url = new URL(request.url);
const page = url.searchParams.get('page');
if (page === '2') {
return HttpResponse.json(PAGE_2);
}
return HttpResponse.json(PAGE_1);
}),
);
const user = userEvent.setup();
render(<GitHubPanel />);
await screen.findByText('v1.0.0');
// All 10 items from page 1 visible
expect(screen.getAllByText(/v1\.\d\.0/).length).toBe(10);
// Click Load more
await user.click(screen.getByText('Load more'));
// Wait for page 2 items to appear
await screen.findByText('v0.0.0');
// Total: 10 from page 1 + 5 from page 2 = 15
const tagEls = screen.getAllByText(/^v[01]\.\d\.0$/);
expect(tagEls.length).toBe(15);
// Load more should be hidden (PAGE_2 < 10)
expect(screen.queryByText('Load more')).not.toBeInTheDocument();
});
});