mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
test: expand frontend test suite to 82% coverage
Adds ~45 new and updated test files covering Admin, Collab, Dashboard, Map, Memories, PDF, Photos, Planner, Settings, Vacay, Weather components, pages, stores, and a WebSocket integration test.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import AboutTab from './AboutTab';
|
||||
|
||||
@@ -82,4 +82,70 @@ describe('AboutTab', () => {
|
||||
expect(screen.getByText('v1.0.0')).toBeInTheDocument();
|
||||
expect(screen.queryByText('v2.9.10')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-ABOUT-012: Ko-fi link hover changes border and box-shadow styles', () => {
|
||||
render(<AboutTab appVersion="1.0.0" />);
|
||||
const link = screen.getByText('Ko-fi').closest('a') as HTMLAnchorElement;
|
||||
fireEvent.mouseEnter(link);
|
||||
expect(link.style.borderColor).toBe('rgb(255, 94, 91)');
|
||||
expect(link.style.boxShadow).not.toBe('');
|
||||
fireEvent.mouseLeave(link);
|
||||
expect(link.style.borderColor).toBe('var(--border-primary)');
|
||||
expect(link.style.boxShadow).toBe('none');
|
||||
});
|
||||
|
||||
it('FE-COMP-ABOUT-013: Buy Me a Coffee link hover changes border and box-shadow styles', () => {
|
||||
render(<AboutTab appVersion="1.0.0" />);
|
||||
const link = screen.getByText('Buy Me a Coffee').closest('a') as HTMLAnchorElement;
|
||||
fireEvent.mouseEnter(link);
|
||||
expect(link.style.borderColor).toBe('rgb(255, 221, 0)');
|
||||
expect(link.style.boxShadow).not.toBe('');
|
||||
fireEvent.mouseLeave(link);
|
||||
expect(link.style.borderColor).toBe('var(--border-primary)');
|
||||
expect(link.style.boxShadow).toBe('none');
|
||||
});
|
||||
|
||||
it('FE-COMP-ABOUT-014: Discord link hover changes border and box-shadow styles', () => {
|
||||
render(<AboutTab appVersion="1.0.0" />);
|
||||
const link = screen.getByText('Discord').closest('a') as HTMLAnchorElement;
|
||||
fireEvent.mouseEnter(link);
|
||||
expect(link.style.borderColor).toBe('rgb(88, 101, 242)');
|
||||
expect(link.style.boxShadow).not.toBe('');
|
||||
fireEvent.mouseLeave(link);
|
||||
expect(link.style.borderColor).toBe('var(--border-primary)');
|
||||
expect(link.style.boxShadow).toBe('none');
|
||||
});
|
||||
|
||||
it('FE-COMP-ABOUT-015: Bug report link hover changes border and box-shadow styles', () => {
|
||||
render(<AboutTab appVersion="1.0.0" />);
|
||||
const link = document.querySelector('a[href*="issues/new"]') as HTMLAnchorElement;
|
||||
fireEvent.mouseEnter(link);
|
||||
expect(link.style.borderColor).toBe('rgb(239, 68, 68)');
|
||||
expect(link.style.boxShadow).not.toBe('');
|
||||
fireEvent.mouseLeave(link);
|
||||
expect(link.style.borderColor).toBe('var(--border-primary)');
|
||||
expect(link.style.boxShadow).toBe('none');
|
||||
});
|
||||
|
||||
it('FE-COMP-ABOUT-016: Feature request link hover changes border and box-shadow styles', () => {
|
||||
render(<AboutTab appVersion="1.0.0" />);
|
||||
const link = document.querySelector('a[href*="discussions/new"]') as HTMLAnchorElement;
|
||||
fireEvent.mouseEnter(link);
|
||||
expect(link.style.borderColor).toBe('rgb(245, 158, 11)');
|
||||
expect(link.style.boxShadow).not.toBe('');
|
||||
fireEvent.mouseLeave(link);
|
||||
expect(link.style.borderColor).toBe('var(--border-primary)');
|
||||
expect(link.style.boxShadow).toBe('none');
|
||||
});
|
||||
|
||||
it('FE-COMP-ABOUT-017: Wiki link hover changes border and box-shadow styles', () => {
|
||||
render(<AboutTab appVersion="1.0.0" />);
|
||||
const link = document.querySelector('a[href*="wiki"]') as HTMLAnchorElement;
|
||||
fireEvent.mouseEnter(link);
|
||||
expect(link.style.borderColor).toBe('rgb(99, 102, 241)');
|
||||
expect(link.style.boxShadow).not.toBe('');
|
||||
fireEvent.mouseLeave(link);
|
||||
expect(link.style.borderColor).toBe('var(--border-primary)');
|
||||
expect(link.style.boxShadow).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
import React from 'react';
|
||||
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 { ToastContainer } from '../shared/Toast';
|
||||
import NotificationsTab from './NotificationsTab';
|
||||
|
||||
const minimalMatrix = {
|
||||
preferences: {
|
||||
trip_invite: { inapp: true, email: false },
|
||||
},
|
||||
available_channels: { email: true, webhook: false, inapp: true },
|
||||
event_types: ['trip_invite'],
|
||||
implemented_combos: { trip_invite: ['inapp', 'email'] },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
server.use(
|
||||
http.get('/api/notifications/preferences', () => HttpResponse.json(minimalMatrix)),
|
||||
http.get('/api/settings', () => HttpResponse.json({ settings: { webhook_url: '' } })),
|
||||
http.put('/api/notifications/preferences', () => HttpResponse.json({ success: true })),
|
||||
);
|
||||
});
|
||||
|
||||
describe('NotificationsTab', () => {
|
||||
it('FE-COMP-NOTIFICATIONS-001: shows loading state initially', () => {
|
||||
server.use(
|
||||
http.get('/api/notifications/preferences', () => new Promise(() => {})),
|
||||
);
|
||||
render(<NotificationsTab />);
|
||||
expect(screen.getByText('Loading…')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTIFICATIONS-002: renders the matrix after preferences load', async () => {
|
||||
render(<NotificationsTab />);
|
||||
// The event label is translated; fallback is the key itself
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
});
|
||||
// Should render a toggle (ToggleSwitch renders a button)
|
||||
const toggles = await screen.findAllByRole('button');
|
||||
expect(toggles.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTIFICATIONS-003: renders channel header labels', async () => {
|
||||
render(<NotificationsTab />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
});
|
||||
// inapp channel header should appear (either translated or raw key)
|
||||
const headers = screen.getAllByText(/inapp|in.?app/i);
|
||||
expect(headers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTIFICATIONS-004: shows "no channels" message when no channels are available', async () => {
|
||||
server.use(
|
||||
http.get('/api/notifications/preferences', () =>
|
||||
HttpResponse.json({
|
||||
preferences: {},
|
||||
available_channels: { email: false, webhook: false, inapp: false },
|
||||
event_types: ['trip_invite'],
|
||||
implemented_combos: { trip_invite: ['inapp', 'email'] },
|
||||
}),
|
||||
),
|
||||
);
|
||||
render(<NotificationsTab />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
});
|
||||
// Should show noChannels message (translated or key)
|
||||
const noChannelEl = await screen.findByText(/no.*channel|noChannels/i);
|
||||
expect(noChannelEl).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTIFICATIONS-005: shows a dash for event/channel combos not implemented', async () => {
|
||||
// Use two events: booking_change only implements email (making email visible),
|
||||
// but trip_invite only implements inapp — so trip_invite row gets a dash for email
|
||||
server.use(
|
||||
http.get('/api/notifications/preferences', () =>
|
||||
HttpResponse.json({
|
||||
preferences: { trip_invite: { inapp: true }, booking_change: { email: true } },
|
||||
available_channels: { email: true, webhook: false, inapp: true },
|
||||
event_types: ['trip_invite', 'booking_change'],
|
||||
implemented_combos: {
|
||||
trip_invite: ['inapp'], // no email → dash in email column
|
||||
booking_change: ['email'], // no inapp → dash in inapp column
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
render(<NotificationsTab />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
});
|
||||
// A dash should appear for non-implemented combos
|
||||
const dashes = await screen.findAllByText('—');
|
||||
expect(dashes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTIFICATIONS-006: clicking a toggle calls the preferences API', async () => {
|
||||
const user = userEvent.setup();
|
||||
let capturedBody: unknown = null;
|
||||
server.use(
|
||||
http.put('/api/notifications/preferences', async ({ request }) => {
|
||||
capturedBody = await request.json();
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
|
||||
render(<NotificationsTab />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// minimalMatrix has inapp:true and email:false for trip_invite
|
||||
// The grid renders email column first, then inapp. We need the inapp toggle.
|
||||
// The inapp toggle is "on" (background accent), email is "off".
|
||||
// Find by looking at all buttons — inapp toggle should be 2nd (index 1) since email column comes first.
|
||||
const toggleButtons = await screen.findAllByRole('button');
|
||||
// There are 2 toggles: email (index 0, off) and inapp (index 1, on)
|
||||
await user.click(toggleButtons[1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedBody).not.toBeNull();
|
||||
});
|
||||
|
||||
// inapp was true, so after click it should be false
|
||||
const body = capturedBody as Record<string, Record<string, boolean>>;
|
||||
expect(body.trip_invite?.inapp).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTIFICATIONS-007: toggle rolls back on API error', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.put('/api/notifications/preferences', () => HttpResponse.json({ error: 'fail' }, { status: 500 })),
|
||||
);
|
||||
|
||||
render(<NotificationsTab />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find the inapp toggle for trip_invite — it starts as "on"
|
||||
const toggleButtons = await screen.findAllByRole('button');
|
||||
const toggleBtn = toggleButtons[0];
|
||||
|
||||
// Verify the initial state via aria-checked or style; click and wait for rollback
|
||||
await user.click(toggleBtn);
|
||||
|
||||
// After the error, the toggle should revert back (still rendered in the DOM)
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Saving…')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The toggle should still be present (not removed on error)
|
||||
const buttonsAfter = screen.getAllByRole('button');
|
||||
expect(buttonsAfter.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTIFICATIONS-008: shows "Saving…" indicator while update is in flight', async () => {
|
||||
const user = userEvent.setup();
|
||||
let resolveRequest!: () => void;
|
||||
server.use(
|
||||
http.put('/api/notifications/preferences', () =>
|
||||
new Promise<Response>(resolve => {
|
||||
resolveRequest = () => resolve(HttpResponse.json({ success: true }) as unknown as Response);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
render(<NotificationsTab />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const toggleButtons = await screen.findAllByRole('button');
|
||||
await user.click(toggleButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Saving…')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
resolveRequest();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Saving…')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTIFICATIONS-009: webhook URL section renders when webhook channel is available', async () => {
|
||||
server.use(
|
||||
http.get('/api/notifications/preferences', () =>
|
||||
HttpResponse.json({
|
||||
preferences: { trip_invite: { inapp: true, webhook: false } },
|
||||
available_channels: { email: false, webhook: true, inapp: true },
|
||||
event_types: ['trip_invite'],
|
||||
implemented_combos: { trip_invite: ['inapp', 'webhook'] },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
render(<NotificationsTab />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Webhook URL input should be present
|
||||
const input = await screen.findByRole('textbox');
|
||||
expect(input).toBeInTheDocument();
|
||||
|
||||
// Save button should be present
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons.some(b => /save/i.test(b.textContent || ''))).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTIFICATIONS-010: webhook URL input shows masked placeholder when webhook is already set', async () => {
|
||||
server.use(
|
||||
http.get('/api/notifications/preferences', () =>
|
||||
HttpResponse.json({
|
||||
preferences: { trip_invite: { inapp: true, webhook: false } },
|
||||
available_channels: { email: false, webhook: true, inapp: true },
|
||||
event_types: ['trip_invite'],
|
||||
implemented_combos: { trip_invite: ['inapp', 'webhook'] },
|
||||
}),
|
||||
),
|
||||
http.get('/api/settings', () =>
|
||||
HttpResponse.json({ settings: { webhook_url: '••••••••' } }),
|
||||
),
|
||||
);
|
||||
|
||||
render(<NotificationsTab />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const input = await screen.findByRole('textbox');
|
||||
expect(input).toHaveAttribute('placeholder', '••••••••');
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTIFICATIONS-011: clicking Save webhook calls settings API', async () => {
|
||||
const user = userEvent.setup();
|
||||
let capturedBody: unknown = null;
|
||||
server.use(
|
||||
http.get('/api/notifications/preferences', () =>
|
||||
HttpResponse.json({
|
||||
preferences: { trip_invite: { inapp: true, webhook: false } },
|
||||
available_channels: { email: false, webhook: true, inapp: true },
|
||||
event_types: ['trip_invite'],
|
||||
implemented_combos: { trip_invite: ['inapp', 'webhook'] },
|
||||
}),
|
||||
),
|
||||
http.put('/api/settings', async ({ request }) => {
|
||||
capturedBody = await request.json();
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
|
||||
render(<NotificationsTab />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const input = await screen.findByRole('textbox');
|
||||
await user.type(input, 'https://example.com/hook');
|
||||
|
||||
const saveBtn = screen.getAllByRole('button').find(b => /save/i.test(b.textContent || ''));
|
||||
expect(saveBtn).toBeDefined();
|
||||
await user.click(saveBtn!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedBody).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTIFICATIONS-012: Test button is disabled when no URL is set and no existing webhook', async () => {
|
||||
server.use(
|
||||
http.get('/api/notifications/preferences', () =>
|
||||
HttpResponse.json({
|
||||
preferences: { trip_invite: { inapp: true, webhook: false } },
|
||||
available_channels: { email: false, webhook: true, inapp: true },
|
||||
event_types: ['trip_invite'],
|
||||
implemented_combos: { trip_invite: ['inapp', 'webhook'] },
|
||||
}),
|
||||
),
|
||||
http.get('/api/settings', () =>
|
||||
HttpResponse.json({ settings: { webhook_url: '' } }),
|
||||
),
|
||||
);
|
||||
|
||||
render(<NotificationsTab />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await screen.findByRole('textbox');
|
||||
const testBtn = screen.getAllByRole('button').find(b => /test/i.test(b.textContent || ''));
|
||||
expect(testBtn).toBeDefined();
|
||||
expect(testBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTIFICATIONS-013: successful test webhook shows success toast', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/notifications/preferences', () =>
|
||||
HttpResponse.json({
|
||||
preferences: { trip_invite: { inapp: true, webhook: false } },
|
||||
available_channels: { email: false, webhook: true, inapp: true },
|
||||
event_types: ['trip_invite'],
|
||||
implemented_combos: { trip_invite: ['inapp', 'webhook'] },
|
||||
}),
|
||||
),
|
||||
http.post('/api/notifications/test-webhook', () =>
|
||||
HttpResponse.json({ success: true }),
|
||||
),
|
||||
);
|
||||
|
||||
render(
|
||||
<>
|
||||
<NotificationsTab />
|
||||
<ToastContainer />
|
||||
</>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const input = await screen.findByRole('textbox');
|
||||
await user.type(input, 'https://example.com/hook');
|
||||
|
||||
const testBtn = screen.getAllByRole('button').find(b => /test/i.test(b.textContent || ''));
|
||||
expect(testBtn).toBeDefined();
|
||||
await user.click(testBtn!);
|
||||
|
||||
// Success toast should appear
|
||||
await waitFor(() => {
|
||||
const toastText = screen.queryByText(/testSuccess|success|sent/i);
|
||||
expect(toastText).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTIFICATIONS-014: failed test webhook shows error toast with message', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/notifications/preferences', () =>
|
||||
HttpResponse.json({
|
||||
preferences: { trip_invite: { inapp: true, webhook: false } },
|
||||
available_channels: { email: false, webhook: true, inapp: true },
|
||||
event_types: ['trip_invite'],
|
||||
implemented_combos: { trip_invite: ['inapp', 'webhook'] },
|
||||
}),
|
||||
),
|
||||
http.post('/api/notifications/test-webhook', () =>
|
||||
HttpResponse.json({ success: false, error: 'Connection refused' }),
|
||||
),
|
||||
);
|
||||
|
||||
render(
|
||||
<>
|
||||
<NotificationsTab />
|
||||
<ToastContainer />
|
||||
</>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const input = await screen.findByRole('textbox');
|
||||
await user.type(input, 'https://example.com/hook');
|
||||
|
||||
const testBtn = screen.getAllByRole('button').find(b => /test/i.test(b.textContent || ''));
|
||||
expect(testBtn).toBeDefined();
|
||||
await user.click(testBtn!);
|
||||
|
||||
// Error toast with 'Connection refused' should appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Connection refused')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,331 @@
|
||||
// FE-COMP-PHOTOPROVIDERS-001 to FE-COMP-PHOTOPROVIDERS-018
|
||||
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 { useAddonStore } from '../../store/addonStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
import PhotoProvidersSection from './PhotoProvidersSection';
|
||||
|
||||
const fakeProvider = {
|
||||
id: 'immich',
|
||||
name: 'Immich',
|
||||
type: 'photo_provider',
|
||||
enabled: true,
|
||||
config: {
|
||||
settings_get: '/addons/immich/settings',
|
||||
settings_put: '/addons/immich/settings',
|
||||
status_get: '/addons/immich/status',
|
||||
test_post: '/addons/immich/test',
|
||||
},
|
||||
fields: [
|
||||
{ key: 'url', label: 'url', input_type: 'text', placeholder: 'https://...', required: true, secret: false, settings_key: 'url', payload_key: 'url', sort_order: 0 },
|
||||
{ key: 'api_key', label: 'api_key', input_type: 'text', placeholder: null, required: true, secret: true, settings_key: 'api_key', payload_key: 'api_key', sort_order: 1 },
|
||||
],
|
||||
};
|
||||
|
||||
// A simpler provider with only a non-secret required field (url), useful for Save tests
|
||||
const fakeProviderSimple = {
|
||||
...fakeProvider,
|
||||
fields: [fakeProvider.fields[0]], // only the url field
|
||||
};
|
||||
|
||||
function seedMemoriesEnabled(providers = [fakeProvider]) {
|
||||
seedStore(useAddonStore, {
|
||||
addons: [
|
||||
{ id: 'memories', type: 'memories', enabled: true },
|
||||
...providers,
|
||||
],
|
||||
isEnabled: (id: string) => id === 'memories' || providers.some(p => p.id === id),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
seedStore(useAddonStore, {
|
||||
addons: [],
|
||||
isEnabled: () => false,
|
||||
});
|
||||
server.use(
|
||||
http.get('/api/addons/immich/settings', () => HttpResponse.json({ url: 'https://photos.example.com', connected: false })),
|
||||
http.get('/api/addons/immich/status', () => HttpResponse.json({ connected: false })),
|
||||
http.put('/api/addons/immich/settings', () => HttpResponse.json({ success: true })),
|
||||
http.post('/api/addons/immich/test', () => HttpResponse.json({ connected: true })),
|
||||
);
|
||||
});
|
||||
|
||||
describe('PhotoProvidersSection', () => {
|
||||
it('FE-COMP-PHOTOPROVIDERS-001: renders nothing when memories addon is disabled', () => {
|
||||
const { container } = render(<PhotoProvidersSection />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('FE-COMP-PHOTOPROVIDERS-002: renders nothing when there are no active photo providers', async () => {
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'memories', type: 'memories', enabled: true }],
|
||||
isEnabled: (id: string) => id === 'memories',
|
||||
});
|
||||
const { container } = render(<PhotoProvidersSection />);
|
||||
// Give the component a moment to potentially render something
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
expect(container.querySelector('section, [class*="section"]')).toBeNull();
|
||||
expect(screen.queryByText('Immich')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-PHOTOPROVIDERS-003: renders a section card for each active provider', async () => {
|
||||
seedMemoriesEnabled();
|
||||
render(<PhotoProvidersSection />);
|
||||
await screen.findByText('Immich');
|
||||
});
|
||||
|
||||
it('FE-COMP-PHOTOPROVIDERS-004: renders field inputs for each provider field', async () => {
|
||||
seedMemoriesEnabled();
|
||||
render(<PhotoProvidersSection />);
|
||||
await screen.findByText('Immich');
|
||||
const inputs = screen.getAllByRole('textbox');
|
||||
expect(inputs.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('FE-COMP-PHOTOPROVIDERS-005: non-secret field is prefilled with value from settings API', async () => {
|
||||
seedMemoriesEnabled();
|
||||
render(<PhotoProvidersSection />);
|
||||
await screen.findByDisplayValue('https://photos.example.com');
|
||||
});
|
||||
|
||||
it('FE-COMP-PHOTOPROVIDERS-006: secret field is NOT prefilled (blank value)', async () => {
|
||||
server.use(
|
||||
http.get('/api/addons/immich/settings', () =>
|
||||
HttpResponse.json({ url: 'https://photos.example.com', api_key: 'super-secret-key', connected: false }),
|
||||
),
|
||||
);
|
||||
seedMemoriesEnabled();
|
||||
render(<PhotoProvidersSection />);
|
||||
await screen.findByText('Immich');
|
||||
await screen.findByDisplayValue('https://photos.example.com');
|
||||
// api_key field should remain blank
|
||||
const inputs = screen.getAllByRole('textbox');
|
||||
const apiKeyInput = inputs.find(i => (i as HTMLInputElement).value === '');
|
||||
expect(apiKeyInput).toBeDefined();
|
||||
expect((apiKeyInput as HTMLInputElement).value).toBe('');
|
||||
});
|
||||
|
||||
it('FE-COMP-PHOTOPROVIDERS-007: secret field shows masked placeholder when connected', async () => {
|
||||
server.use(
|
||||
http.get('/api/addons/immich/settings', () =>
|
||||
HttpResponse.json({ url: 'https://photos.example.com', connected: true }),
|
||||
),
|
||||
http.get('/api/addons/immich/status', () => HttpResponse.json({ connected: true })),
|
||||
);
|
||||
seedMemoriesEnabled();
|
||||
render(<PhotoProvidersSection />);
|
||||
await screen.findByText('Immich');
|
||||
await waitFor(() => {
|
||||
const inputs = screen.getAllByRole('textbox');
|
||||
const maskedInput = inputs.find(i => (i as HTMLInputElement).placeholder === '••••••••');
|
||||
expect(maskedInput).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-PHOTOPROVIDERS-008: Save button is disabled when required non-secret field is empty', async () => {
|
||||
server.use(
|
||||
http.get('/api/addons/immich/settings', () => HttpResponse.json({ url: '', connected: false })),
|
||||
);
|
||||
seedMemoriesEnabled();
|
||||
render(<PhotoProvidersSection />);
|
||||
await screen.findByText('Immich');
|
||||
await waitFor(() => {
|
||||
const saveBtn = screen.getByRole('button', { name: /save/i });
|
||||
expect(saveBtn).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-PHOTOPROVIDERS-009: Save button is enabled when all required fields are filled', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedMemoriesEnabled();
|
||||
render(<PhotoProvidersSection />);
|
||||
// url is prefilled, but api_key (required + secret) must also be filled
|
||||
await screen.findByDisplayValue('https://photos.example.com');
|
||||
const inputs = screen.getAllByRole('textbox');
|
||||
const apiKeyInput = inputs.find(i => (i as HTMLInputElement).value === '') as HTMLInputElement;
|
||||
await user.type(apiKeyInput, 'some-api-key');
|
||||
await waitFor(() => {
|
||||
const saveBtn = screen.getByRole('button', { name: /save/i });
|
||||
expect(saveBtn).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-PHOTOPROVIDERS-010: clicking Save calls PUT settings endpoint', async () => {
|
||||
const user = userEvent.setup();
|
||||
let putCalled = false;
|
||||
server.use(
|
||||
http.put('/api/addons/immich/settings', () => {
|
||||
putCalled = true;
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
seedMemoriesEnabled([fakeProviderSimple]);
|
||||
render(<PhotoProvidersSection />);
|
||||
await screen.findByDisplayValue('https://photos.example.com');
|
||||
const saveBtn = await screen.findByRole('button', { name: /save/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
await user.click(saveBtn);
|
||||
await waitFor(() => expect(putCalled).toBe(true));
|
||||
});
|
||||
|
||||
it('FE-COMP-PHOTOPROVIDERS-011: successful save shows success toast', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedMemoriesEnabled([fakeProviderSimple]);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<PhotoProvidersSection />
|
||||
</>,
|
||||
);
|
||||
await screen.findByDisplayValue('https://photos.example.com');
|
||||
const saveBtn = await screen.findByRole('button', { name: /save/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
await user.click(saveBtn);
|
||||
await screen.findByText(/immich settings saved/i);
|
||||
});
|
||||
|
||||
it('FE-COMP-PHOTOPROVIDERS-012: failed save shows error toast', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.put('/api/addons/immich/settings', () => HttpResponse.json({ error: 'Server error' }, { status: 500 })),
|
||||
);
|
||||
seedMemoriesEnabled([fakeProviderSimple]);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<PhotoProvidersSection />
|
||||
</>,
|
||||
);
|
||||
await screen.findByDisplayValue('https://photos.example.com');
|
||||
const saveBtn = await screen.findByRole('button', { name: /save/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
await user.click(saveBtn);
|
||||
await screen.findByText(/could not save immich/i);
|
||||
});
|
||||
|
||||
it('FE-COMP-PHOTOPROVIDERS-013: clicking Test Connection calls the test endpoint', async () => {
|
||||
const user = userEvent.setup();
|
||||
let testCalled = false;
|
||||
server.use(
|
||||
http.post('/api/addons/immich/test', () => {
|
||||
testCalled = true;
|
||||
return HttpResponse.json({ connected: true });
|
||||
}),
|
||||
);
|
||||
seedMemoriesEnabled();
|
||||
render(<PhotoProvidersSection />);
|
||||
await screen.findByText('Immich');
|
||||
const testBtn = screen.getByRole('button', { name: /test connection/i });
|
||||
await user.click(testBtn);
|
||||
await waitFor(() => expect(testCalled).toBe(true));
|
||||
});
|
||||
|
||||
it('FE-COMP-PHOTOPROVIDERS-014: successful test shows "Connected" badge', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/addons/immich/test', () => HttpResponse.json({ connected: true })),
|
||||
);
|
||||
seedMemoriesEnabled();
|
||||
render(<PhotoProvidersSection />);
|
||||
await screen.findByText('Immich');
|
||||
const testBtn = screen.getByRole('button', { name: /test connection/i });
|
||||
await user.click(testBtn);
|
||||
await screen.findByText(/connected/i);
|
||||
});
|
||||
|
||||
it('FE-COMP-PHOTOPROVIDERS-015: failed test shows error toast', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/addons/immich/test', () => HttpResponse.json({ connected: false, error: 'Auth failed' })),
|
||||
);
|
||||
seedMemoriesEnabled();
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<PhotoProvidersSection />
|
||||
</>,
|
||||
);
|
||||
await screen.findByText('Immich');
|
||||
const testBtn = screen.getByRole('button', { name: /test connection/i });
|
||||
await user.click(testBtn);
|
||||
await screen.findByText(/Auth failed/i);
|
||||
});
|
||||
|
||||
it('FE-COMP-PHOTOPROVIDERS-016: Test button is disabled while test is in progress', async () => {
|
||||
const user = userEvent.setup();
|
||||
let resolveTest!: () => void;
|
||||
server.use(
|
||||
http.post('/api/addons/immich/test', async () => {
|
||||
await new Promise<void>(resolve => {
|
||||
resolveTest = resolve;
|
||||
});
|
||||
return HttpResponse.json({ connected: true });
|
||||
}),
|
||||
);
|
||||
seedMemoriesEnabled();
|
||||
render(<PhotoProvidersSection />);
|
||||
await screen.findByText('Immich');
|
||||
const testBtn = screen.getByRole('button', { name: /test connection/i });
|
||||
await user.click(testBtn);
|
||||
await waitFor(() => expect(testBtn).toBeDisabled());
|
||||
resolveTest();
|
||||
await waitFor(() => expect(testBtn).not.toBeDisabled());
|
||||
});
|
||||
|
||||
it('FE-COMP-PHOTOPROVIDERS-017: Save button is disabled while saving', async () => {
|
||||
const user = userEvent.setup();
|
||||
let resolveSave!: () => void;
|
||||
server.use(
|
||||
http.put('/api/addons/immich/settings', async () => {
|
||||
await new Promise<void>(resolve => {
|
||||
resolveSave = resolve;
|
||||
});
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
seedMemoriesEnabled([fakeProviderSimple]);
|
||||
render(<PhotoProvidersSection />);
|
||||
await screen.findByDisplayValue('https://photos.example.com');
|
||||
const saveBtn = await screen.findByRole('button', { name: /save/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
await user.click(saveBtn);
|
||||
await waitFor(() => expect(saveBtn).toBeDisabled());
|
||||
resolveSave();
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
});
|
||||
|
||||
it('FE-COMP-PHOTOPROVIDERS-018: multiple providers each get their own Section card', async () => {
|
||||
const secondProvider = {
|
||||
id: 'piwigo',
|
||||
name: 'Piwigo',
|
||||
type: 'photo_provider',
|
||||
enabled: true,
|
||||
config: {
|
||||
settings_get: '/addons/piwigo/settings',
|
||||
settings_put: '/addons/piwigo/settings',
|
||||
status_get: '/addons/piwigo/status',
|
||||
test_post: '/addons/piwigo/test',
|
||||
},
|
||||
fields: [
|
||||
{ key: 'url', label: 'url', input_type: 'text', placeholder: 'https://...', required: true, secret: false, settings_key: 'url', payload_key: 'url', sort_order: 0 },
|
||||
],
|
||||
};
|
||||
server.use(
|
||||
http.get('/api/addons/piwigo/settings', () => HttpResponse.json({ url: '', connected: false })),
|
||||
http.get('/api/addons/piwigo/status', () => HttpResponse.json({ connected: false })),
|
||||
);
|
||||
seedMemoriesEnabled([fakeProvider, secondProvider]);
|
||||
render(<PhotoProvidersSection />);
|
||||
await screen.findByText('Immich');
|
||||
await screen.findByText('Piwigo');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import ToggleSwitch from './ToggleSwitch';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('ToggleSwitch', () => {
|
||||
it('FE-COMP-TOGGLESWITCH-001: renders a button', () => {
|
||||
render(<ToggleSwitch on={false} onToggle={() => {}} />);
|
||||
expect(screen.getByRole('button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-TOGGLESWITCH-002: knob is positioned left when on is false', () => {
|
||||
render(<ToggleSwitch on={false} onToggle={() => {}} />);
|
||||
const button = screen.getByRole('button');
|
||||
const knob = button.querySelector('span')!;
|
||||
expect(knob.style.left).toBe('2px');
|
||||
});
|
||||
|
||||
it('FE-COMP-TOGGLESWITCH-003: knob is positioned right when on is true', () => {
|
||||
render(<ToggleSwitch on={true} onToggle={() => {}} />);
|
||||
const button = screen.getByRole('button');
|
||||
const knob = button.querySelector('span')!;
|
||||
expect(knob.style.left).toBe('22px');
|
||||
});
|
||||
|
||||
it('FE-COMP-TOGGLESWITCH-004: background uses accent variable when on is true', () => {
|
||||
render(<ToggleSwitch on={true} onToggle={() => {}} />);
|
||||
const button = screen.getByRole('button');
|
||||
expect(button.style.background).toContain('var(--accent');
|
||||
});
|
||||
|
||||
it('FE-COMP-TOGGLESWITCH-005: background uses border-primary variable when on is false', () => {
|
||||
render(<ToggleSwitch on={false} onToggle={() => {}} />);
|
||||
const button = screen.getByRole('button');
|
||||
expect(button.style.background).toContain('var(--border-primary');
|
||||
});
|
||||
|
||||
it('FE-COMP-TOGGLESWITCH-006: clicking the button calls onToggle', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggle = vi.fn();
|
||||
render(<ToggleSwitch on={false} onToggle={onToggle} />);
|
||||
await user.click(screen.getByRole('button'));
|
||||
expect(onToggle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-TOGGLESWITCH-007: clicking does not change visual state without parent update', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ToggleSwitch on={false} onToggle={() => {}} />);
|
||||
const button = screen.getByRole('button');
|
||||
await user.click(button);
|
||||
expect(button.querySelector('span')!.style.left).toBe('2px');
|
||||
});
|
||||
|
||||
it('FE-COMP-TOGGLESWITCH-008: re-renders correctly when on prop changes from false to true', () => {
|
||||
const { rerender } = render(<ToggleSwitch on={false} onToggle={() => {}} />);
|
||||
const button = screen.getByRole('button');
|
||||
expect(button.querySelector('span')!.style.left).toBe('2px');
|
||||
rerender(<ToggleSwitch on={true} onToggle={() => {}} />);
|
||||
expect(button.querySelector('span')!.style.left).toBe('22px');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user