mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
test(mobile): cover admin, settings and the remaining screens
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
import { vi } from 'vitest';
|
||||
import type { useAdmin } from '../../src/pages/admin/useAdmin';
|
||||
import type { AdminUser } from '../../src/pages/admin/adminModel';
|
||||
|
||||
/**
|
||||
* Fixture for the mobile admin sections. MAdminUsersSection, MAdminSettingsSection
|
||||
* and MAdminSheets are pure presentation over the useAdmin() bag, so tests render
|
||||
* them with this builder instead of mounting MAdmin and its data loading.
|
||||
*
|
||||
* Defaults are the "nothing configured yet" state; every setter and handler is a
|
||||
* spy so tests can assert the exact call, including state updater functions.
|
||||
*/
|
||||
|
||||
export type AdminHook = ReturnType<typeof useAdmin>;
|
||||
|
||||
export function buildAdminUser(overrides: Partial<AdminUser> = {}): AdminUser {
|
||||
return {
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'admin@example.com',
|
||||
role: 'admin',
|
||||
created_at: '2025-01-01T00:00:00.000Z',
|
||||
avatar_url: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAdminToast(): Record<string, ReturnType<typeof vi.fn>> {
|
||||
return { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() };
|
||||
}
|
||||
|
||||
export function buildAdminHook(overrides: Record<string, unknown> = {}): AdminHook {
|
||||
const base: Record<string, unknown> = {
|
||||
// store-derived
|
||||
demoMode: false,
|
||||
serverTimezone: 'UTC',
|
||||
hour12: false,
|
||||
mcpEnabled: false,
|
||||
devMode: false,
|
||||
currentUser: buildAdminUser(),
|
||||
updateApiKeys: vi.fn(async () => undefined),
|
||||
setAppRequireMfa: vi.fn(),
|
||||
setTripRemindersEnabled: vi.fn(),
|
||||
setPlacesPhotosEnabled: vi.fn(),
|
||||
setPlacesAutocompleteEnabled: vi.fn(),
|
||||
setPlacesDetailsEnabled: vi.fn(),
|
||||
logout: vi.fn(async () => undefined),
|
||||
navigate: vi.fn(),
|
||||
toast: buildAdminToast(),
|
||||
|
||||
// users
|
||||
activeTab: 'users',
|
||||
setActiveTab: vi.fn(),
|
||||
users: [] as AdminUser[],
|
||||
setUsers: vi.fn(),
|
||||
stats: null,
|
||||
isLoading: false,
|
||||
editingUser: null,
|
||||
setEditingUser: vi.fn(),
|
||||
editForm: { username: '', email: '', role: 'user', password: '' },
|
||||
setEditForm: vi.fn(),
|
||||
showCreateUser: false,
|
||||
setShowCreateUser: vi.fn(),
|
||||
createForm: { username: '', email: '', password: '', role: 'user' },
|
||||
setCreateForm: vi.fn(),
|
||||
|
||||
// feature flags
|
||||
bagTrackingEnabled: false,
|
||||
setBagTrackingEnabled: vi.fn(),
|
||||
placesPhotosEnabled: false,
|
||||
setPlacesPhotosEnabledState: vi.fn(),
|
||||
placesAutocompleteEnabled: false,
|
||||
setPlacesAutocompleteEnabledState: vi.fn(),
|
||||
placesDetailsEnabled: false,
|
||||
setPlacesDetailsEnabledState: vi.fn(),
|
||||
collabFeatures: { chat: true, notes: true, polls: true, whatsnext: true },
|
||||
setCollabFeatures: vi.fn(),
|
||||
|
||||
// auth / OIDC
|
||||
oidcConfig: {
|
||||
issuer: '',
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
client_secret_set: false,
|
||||
display_name: '',
|
||||
discovery_url: '',
|
||||
},
|
||||
setOidcConfig: vi.fn(),
|
||||
savingOidc: false,
|
||||
setSavingOidc: vi.fn(),
|
||||
passwordLogin: true,
|
||||
setPasswordLogin: vi.fn(),
|
||||
passwordRegistration: true,
|
||||
setPasswordRegistration: vi.fn(),
|
||||
oidcLogin: false,
|
||||
setOidcLogin: vi.fn(),
|
||||
oidcRegistration: false,
|
||||
setOidcRegistration: vi.fn(),
|
||||
envOverrideOidcOnly: false,
|
||||
setEnvOverrideOidcOnly: vi.fn(),
|
||||
oidcConfigured: false,
|
||||
setOidcConfigured: vi.fn(),
|
||||
requireMfa: false,
|
||||
setRequireMfa: vi.fn(),
|
||||
passkeyLogin: false,
|
||||
setPasskeyLogin: vi.fn(),
|
||||
passkeyConfigured: false,
|
||||
webauthnRpId: '',
|
||||
setWebauthnRpId: vi.fn(),
|
||||
webauthnOrigins: '',
|
||||
setWebauthnOrigins: vi.fn(),
|
||||
savingWebauthn: false,
|
||||
handleSaveWebauthn: vi.fn(),
|
||||
|
||||
// invites
|
||||
invites: [] as unknown[],
|
||||
setInvites: vi.fn(),
|
||||
inviteTrips: [] as { id: number; title: string }[],
|
||||
showCreateInvite: false,
|
||||
setShowCreateInvite: vi.fn(),
|
||||
inviteForm: { max_uses: 1, expires_in_days: 7, trip_id: '' },
|
||||
setInviteForm: vi.fn(),
|
||||
|
||||
// misc settings
|
||||
allowedFileTypes: 'jpg,png,pdf',
|
||||
setAllowedFileTypes: vi.fn(),
|
||||
savingFileTypes: false,
|
||||
setSavingFileTypes: vi.fn(),
|
||||
smtpValues: {},
|
||||
setSmtpValues: vi.fn(),
|
||||
smtpLoaded: true,
|
||||
mapsKey: '',
|
||||
setMapsKey: vi.fn(),
|
||||
weatherKey: '',
|
||||
setWeatherKey: vi.fn(),
|
||||
unsplashKey: '',
|
||||
setUnsplashKey: vi.fn(),
|
||||
showKeys: {},
|
||||
setShowKeys: vi.fn(),
|
||||
savingKeys: false,
|
||||
validating: {},
|
||||
validation: {},
|
||||
|
||||
// update / danger zone
|
||||
updateInfo: null,
|
||||
setUpdateInfo: vi.fn(),
|
||||
showUpdateModal: false,
|
||||
setShowUpdateModal: vi.fn(),
|
||||
showRotateJwtModal: false,
|
||||
setShowRotateJwtModal: vi.fn(),
|
||||
rotatingJwt: false,
|
||||
setRotatingJwt: vi.fn(),
|
||||
|
||||
// handlers
|
||||
loadData: vi.fn(),
|
||||
loadAppConfig: vi.fn(),
|
||||
loadApiKeys: vi.fn(),
|
||||
handleToggleAuthSetting: vi.fn(),
|
||||
handleToggleRequireMfa: vi.fn(),
|
||||
toggleKey: vi.fn(),
|
||||
handleSaveApiKeys: vi.fn(),
|
||||
handleValidateKeys: vi.fn(),
|
||||
handleValidateKey: vi.fn(),
|
||||
handleCreateUser: vi.fn(),
|
||||
handleCreateInvite: vi.fn(),
|
||||
handleDeleteInvite: vi.fn(),
|
||||
copyInviteLink: vi.fn(),
|
||||
handleEditUser: vi.fn(),
|
||||
handleSaveUser: vi.fn(),
|
||||
handleDeleteUser: vi.fn(),
|
||||
};
|
||||
|
||||
return { ...base, ...overrides } as unknown as AdminHook;
|
||||
}
|
||||
@@ -1,10 +1,21 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { render, screen, fireEvent } from '../../helpers/render';
|
||||
import MBottomNav from '../../../src/mobile/components/MBottomNav';
|
||||
import { useAddonStore } from '../../../src/store/addonStore';
|
||||
import { usePluginStore } from '../../../src/store/pluginStore';
|
||||
|
||||
function LocationEcho() {
|
||||
const location = useLocation();
|
||||
return <span data-testid="loc">{location.pathname + location.search}</span>;
|
||||
}
|
||||
|
||||
const nav = (entry: string) =>
|
||||
render(<><MBottomNav /><LocationEcho /></>, { initialEntries: [entry] });
|
||||
|
||||
const at = () => screen.getByTestId('loc').textContent;
|
||||
|
||||
// FE-MOB-NAV-001 onwards
|
||||
|
||||
function seedAddons(ids: string[]) {
|
||||
@@ -81,4 +92,92 @@ describe('MBottomNav', () => {
|
||||
render(<MBottomNav />, { initialEntries: ['/settings'] });
|
||||
expect(screen.queryByRole('button', { name: 'New Trip' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-NAV-010: admin also swaps the "+" for the logo slot', () => {
|
||||
render(<MBottomNav />, { initialEntries: ['/admin'] });
|
||||
expect(screen.queryByRole('button', { name: 'New Trip' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-NAV-011: choosing a More entry navigates and closes the popover', () => {
|
||||
seedAddons(['vacay', 'atlas', 'journey', 'collections']);
|
||||
render(<MBottomNav />, { initialEntries: ['/dashboard'] });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'More' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Journey/ }));
|
||||
|
||||
const more = screen.getByRole('button', { name: 'More' });
|
||||
expect(more).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(more).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
it('FE-MOB-NAV-012: tapping the scrim closes the More popover again', () => {
|
||||
seedAddons(['vacay', 'atlas', 'journey', 'collections']);
|
||||
const { container } = render(<MBottomNav />, { initialEntries: ['/dashboard'] });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'More' }));
|
||||
const scrim = container.querySelector('.fixed.inset-0') as HTMLElement;
|
||||
|
||||
fireEvent.click(scrim);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'More' })).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.queryByRole('button', { name: /Collections/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-NAV-013: a click inside the popover does not close it', () => {
|
||||
seedAddons(['vacay', 'atlas', 'journey', 'collections']);
|
||||
const { container } = render(<MBottomNav />, { initialEntries: ['/dashboard'] });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'More' }));
|
||||
fireEvent.click(container.querySelector('.fixed.inset-0 > div') as HTMLElement);
|
||||
|
||||
expect(screen.getByRole('button', { name: /Journey/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/journey', 'New Journey', '/journey?create=1'],
|
||||
['/journey/4', 'Add Entry', '/journey/4?create=entry'],
|
||||
['/collections', 'Add a place', '/collections?create=place'],
|
||||
['/trips/7', 'Add Place/Activity', '/trips/7?create=place'],
|
||||
['/dashboard', 'New Trip', '/dashboard?create=1'],
|
||||
['/atlas', 'Search a country...', '/atlas?search=1'],
|
||||
])('FE-MOB-NAV-014: the "+" on %s runs "%s"', (route, label, target) => {
|
||||
seedAddons(['journey', 'collections', 'atlas']);
|
||||
nav(route);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: label }));
|
||||
|
||||
expect(at()).toBe(target);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['finanzplan', 'Add expense', '/trips/7?create=expense'],
|
||||
['transports', 'Transport', '/trips/7?create=transport'],
|
||||
['buchungen', 'Manual Booking', '/trips/7?create=reservation'],
|
||||
])('FE-MOB-NAV-015: the trip "+" follows the %s tab', (tab, label, target) => {
|
||||
sessionStorage.setItem('trip-tab-7', tab);
|
||||
nav('/trips/7');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: label }));
|
||||
|
||||
expect(at()).toBe(target);
|
||||
});
|
||||
|
||||
it('FE-MOB-NAV-016: a dock tab navigates to its route', () => {
|
||||
seedAddons(['vacay', 'atlas']);
|
||||
nav('/dashboard');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Atlas' }));
|
||||
|
||||
expect(at()).toBe('/atlas');
|
||||
});
|
||||
|
||||
it('FE-MOB-NAV-017: a More entry navigates to its route', () => {
|
||||
seedAddons(['vacay', 'atlas', 'journey', 'collections']);
|
||||
nav('/dashboard');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'More' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Collections/ }));
|
||||
|
||||
expect(at()).toBe('/collections');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, fireEvent } from '../../helpers/render';
|
||||
import MDancingTrek, { type TrekScene } from '../../../src/mobile/components/MDancingTrek';
|
||||
|
||||
// FE-MOB-TREKM-001 onwards
|
||||
|
||||
const svgOf = (container: HTMLElement) => container.querySelector('svg') as SVGSVGElement;
|
||||
|
||||
describe('MDancingTrek', () => {
|
||||
it('FE-MOB-TREKM-001: the idle mascot keeps its aspect ratio and open eyes', () => {
|
||||
const { container } = render(<MDancingTrek size={88} className="mb-2" />);
|
||||
const svg = svgOf(container);
|
||||
|
||||
expect(svg).toHaveAttribute('width', '88');
|
||||
expect(svg).toHaveAttribute('height', '96');
|
||||
expect(svg.getAttribute('class')).toContain('trek--idle');
|
||||
expect(svg.getAttribute('class')).toContain('mb-2');
|
||||
expect(container.querySelectorAll('.trek-eye')).toHaveLength(2);
|
||||
expect(container.querySelector('.trek-pupils')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-TREKM-002: the ground shadow drops for the skateboard scene', () => {
|
||||
const { container } = render(<MDancingTrek scene="transport" />);
|
||||
expect(container.querySelector('.trek-shadow')).toHaveAttribute('cy', '86');
|
||||
|
||||
const { container: idle } = render(<MDancingTrek />);
|
||||
expect(idle.querySelector('.trek-shadow')).toHaveAttribute('cy', '73');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['happy', 2, 0],
|
||||
['sleepy', 2, 0],
|
||||
['error', 2, 0],
|
||||
] as const)('FE-MOB-TREKM-003: the %s mood swaps the eyes for drawn strokes', (mood, paths, eyes) => {
|
||||
const { container } = render(<MDancingTrek mood={mood} />);
|
||||
|
||||
expect(container.querySelectorAll('.trek-body g[stroke] path')).toHaveLength(paths);
|
||||
expect(container.querySelectorAll('.trek-eye')).toHaveLength(eyes);
|
||||
});
|
||||
|
||||
it('FE-MOB-TREKM-004: the confused mood keeps one open eye', () => {
|
||||
const { container } = render(<MDancingTrek mood="confused" />);
|
||||
|
||||
expect(container.querySelectorAll('.trek-eye')).toHaveLength(1);
|
||||
expect(container.querySelector('.trek-pupils')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-MOB-TREKM-005: a scene carries its own default mood', () => {
|
||||
const { container: sleepy } = render(<MDancingTrek scene="notifications" />);
|
||||
// sleepy = two drawn arcs, no open eyes
|
||||
expect(sleepy.querySelectorAll('.trek-eye')).toHaveLength(0);
|
||||
|
||||
const { container: confused } = render(<MDancingTrek scene="search" />);
|
||||
expect(confused.querySelectorAll('.trek-eye')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-TREKM-006: an explicit mood beats the scene default', () => {
|
||||
const { container } = render(<MDancingTrek scene="notifications" mood="default" />);
|
||||
|
||||
expect(container.querySelectorAll('.trek-eye')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['transport', '.trek-board'],
|
||||
['guide', '.trek-guide'],
|
||||
['packing', '.trek-suitcase'],
|
||||
['polls', '.trek-polls'],
|
||||
['collections', '.trek-pin'],
|
||||
['atlas', '.trek-globe-wrap'],
|
||||
['costs', '.trek-coins'],
|
||||
['chat', '.trek-chat'],
|
||||
['bookings', '.trek-ticket'],
|
||||
['files', '.trek-ticket'],
|
||||
['notes', '.trek-note'],
|
||||
['journey', '.trek-journal'],
|
||||
['dashboard', '.trek-plane'],
|
||||
['notifications', '.trek-zzz'],
|
||||
['search', '.trek-magnifier'],
|
||||
['tasks', '.trek-tasks'],
|
||||
] as Array<[TrekScene, string]>)('FE-MOB-TREKM-007: the %s scene brings its own prop', (scene, selector) => {
|
||||
const { container } = render(<MDancingTrek scene={scene} />);
|
||||
|
||||
expect(container.querySelector(selector)).toBeInTheDocument();
|
||||
expect(container.querySelector(`.trek-bounce--${scene}`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-TREKM-008: the idle scene has no prop at all', () => {
|
||||
const { container } = render(<MDancingTrek scene="idle" />);
|
||||
|
||||
expect(container.querySelector('.trek-root')?.children).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-TREKM-009: poking the mascot remounts the svg to replay the pop-in', () => {
|
||||
const { container } = render(<MDancingTrek />);
|
||||
const before = svgOf(container);
|
||||
|
||||
fireEvent.click(before);
|
||||
|
||||
expect(svgOf(container)).not.toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -79,6 +79,161 @@ describe('MSheet', () => {
|
||||
expect(screen.queryByText('Sheet content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SHEET-007: Tab wraps around inside the dialog', () => {
|
||||
render(
|
||||
<MSheet open onClose={() => {}} ariaLabel="Trap">
|
||||
<button type="button">first</button>
|
||||
<button type="button">last</button>
|
||||
</MSheet>,
|
||||
);
|
||||
const dialog = screen.getByRole('dialog');
|
||||
const first = screen.getByText('first');
|
||||
const last = screen.getByText('last');
|
||||
|
||||
last.focus();
|
||||
fireEvent.keyDown(dialog, { key: 'Tab' });
|
||||
expect(document.activeElement).toBe(first);
|
||||
|
||||
fireEvent.keyDown(dialog, { key: 'Tab', shiftKey: true });
|
||||
expect(document.activeElement).toBe(last);
|
||||
});
|
||||
|
||||
it('FE-MOB-SHEET-008: shift+Tab from the panel itself jumps to the last control', () => {
|
||||
render(
|
||||
<MSheet open onClose={() => {}} ariaLabel="Trap">
|
||||
<button type="button">first</button>
|
||||
<button type="button">last</button>
|
||||
</MSheet>,
|
||||
);
|
||||
const dialog = screen.getByRole('dialog');
|
||||
dialog.focus();
|
||||
|
||||
fireEvent.keyDown(dialog, { key: 'Tab', shiftKey: true });
|
||||
|
||||
expect(document.activeElement).toBe(screen.getByText('last'));
|
||||
});
|
||||
|
||||
it('FE-MOB-SHEET-009: keys other than Tab pass through', () => {
|
||||
render(
|
||||
<MSheet open onClose={() => {}} ariaLabel="Trap">
|
||||
<button type="button">first</button>
|
||||
</MSheet>,
|
||||
);
|
||||
const dialog = screen.getByRole('dialog');
|
||||
const first = screen.getByText('first');
|
||||
first.focus();
|
||||
|
||||
fireEvent.keyDown(dialog, { key: 'a' });
|
||||
|
||||
expect(document.activeElement).toBe(first);
|
||||
});
|
||||
|
||||
it('FE-MOB-SHEET-010: a sheet without focusable content swallows Tab', () => {
|
||||
render(
|
||||
<MSheet open onClose={() => {}} ariaLabel="Empty">
|
||||
<span>nothing to focus</span>
|
||||
</MSheet>,
|
||||
);
|
||||
const dialog = screen.getByRole('dialog');
|
||||
|
||||
const handled = fireEvent.keyDown(dialog, { key: 'Tab' });
|
||||
|
||||
// fireEvent returns false once a listener called preventDefault.
|
||||
expect(handled).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-MOB-SHEET-011: the entrance animation class is dropped once it has played', () => {
|
||||
render(
|
||||
<MSheet open onClose={() => {}} ariaLabel="Anim">
|
||||
<span>Sheet content</span>
|
||||
</MSheet>,
|
||||
);
|
||||
const dialog = screen.getByRole('dialog');
|
||||
expect(dialog.className).toContain('m-sheet-in');
|
||||
|
||||
// jsdom has no AnimationEvent, so React binds the vendor-prefixed name.
|
||||
act(() => {
|
||||
dialog.dispatchEvent(new Event('animationend', { bubbles: true }));
|
||||
dialog.dispatchEvent(new Event('webkitAnimationEnd', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(dialog.className).not.toContain('m-sheet-in');
|
||||
});
|
||||
|
||||
it('FE-MOB-SHEET-012: dragging the bottom sheet far enough dismisses it', () => {
|
||||
HTMLElement.prototype.setPointerCapture = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<MSheet open onClose={onClose} variant="bottom" ariaLabel="Bottom">
|
||||
<span>Sheet content</span>
|
||||
</MSheet>,
|
||||
);
|
||||
const dialog = screen.getByRole('dialog');
|
||||
Object.defineProperty(dialog, 'offsetHeight', { configurable: true, value: 400 });
|
||||
const strip = dialog.querySelector('.cursor-grab') as HTMLElement;
|
||||
|
||||
fireEvent.pointerDown(strip, { pointerId: 1, clientY: 100 });
|
||||
fireEvent.pointerMove(strip, { pointerId: 1, clientY: 260 });
|
||||
expect(dialog.getAttribute('style')).toContain('translateY(160px)');
|
||||
// A slow last move keeps the velocity low, so only the 30% travel rule can close it.
|
||||
fireEvent.pointerMove(strip, { pointerId: 1, clientY: 260.3 });
|
||||
|
||||
fireEvent.pointerUp(strip, { pointerId: 1, clientY: 260.3 });
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-SHEET-015: a quick flick dismisses even without much travel', () => {
|
||||
HTMLElement.prototype.setPointerCapture = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<MSheet open onClose={onClose} variant="bottom" ariaLabel="Bottom">
|
||||
<span>Sheet content</span>
|
||||
</MSheet>,
|
||||
);
|
||||
const strip = screen.getByRole('dialog').querySelector('.cursor-grab') as HTMLElement;
|
||||
|
||||
fireEvent.pointerDown(strip, { pointerId: 1, clientY: 100 });
|
||||
fireEvent.pointerMove(strip, { pointerId: 1, clientY: 130 });
|
||||
fireEvent.pointerUp(strip, { pointerId: 1, clientY: 130 });
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-SHEET-013: a short drag snaps back instead of closing', () => {
|
||||
HTMLElement.prototype.setPointerCapture = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<MSheet open onClose={onClose} variant="bottom" ariaLabel="Bottom">
|
||||
<span>Sheet content</span>
|
||||
</MSheet>,
|
||||
);
|
||||
const dialog = screen.getByRole('dialog');
|
||||
Object.defineProperty(dialog, 'offsetHeight', { configurable: true, value: 400 });
|
||||
const strip = dialog.querySelector('.cursor-grab') as HTMLElement;
|
||||
|
||||
fireEvent.pointerDown(strip, { pointerId: 1, clientY: 100 });
|
||||
fireEvent.pointerMove(strip, { pointerId: 1, clientY: 100.4 });
|
||||
fireEvent.pointerCancel(strip, { pointerId: 1, clientY: 100.4 });
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(dialog.getAttribute('style')).toContain('transform 280ms');
|
||||
});
|
||||
|
||||
it('FE-MOB-SHEET-014: pointer moves without a drag in progress are ignored', () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<MSheet open onClose={onClose} variant="bottom" ariaLabel="Bottom">
|
||||
<span>Sheet content</span>
|
||||
</MSheet>,
|
||||
);
|
||||
const strip = screen.getByRole('dialog').querySelector('.cursor-grab') as HTMLElement;
|
||||
|
||||
fireEvent.pointerMove(strip, { pointerId: 1, clientY: 400 });
|
||||
fireEvent.pointerUp(strip, { pointerId: 1, clientY: 400 });
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-SHEET-006: locks body scroll while open and releases it on close', () => {
|
||||
const { rerender } = render(
|
||||
<MSheet open onClose={() => {}}>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import MToastHost from '../../../src/mobile/components/MToastHost';
|
||||
|
||||
// FE-MOB-MTOAST-001 onwards
|
||||
|
||||
function push(message: string, type?: 'success' | 'error' | 'warning' | 'info', duration?: number): void {
|
||||
act(() => { window.__addToast?.(message, type, duration); });
|
||||
}
|
||||
|
||||
function pill(message: string): HTMLElement {
|
||||
return screen.getByText(message).parentElement as HTMLElement;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
describe('MToastHost', () => {
|
||||
it('FE-MOB-MTOAST-001: renders nothing until a toast arrives', () => {
|
||||
const { container } = render(<MToastHost />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
|
||||
push('Trip saved');
|
||||
expect(screen.getByText('Trip saved')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MTOAST-002: takes over the global bridge and hands it back on unmount', () => {
|
||||
const previous = vi.fn(() => 0);
|
||||
window.__addToast = previous;
|
||||
|
||||
const { unmount } = render(<MToastHost />);
|
||||
expect(window.__addToast).not.toBe(previous);
|
||||
|
||||
unmount();
|
||||
expect(window.__addToast).toBe(previous);
|
||||
});
|
||||
|
||||
it('FE-MOB-MTOAST-003: stacks several toasts and returns rising ids', () => {
|
||||
render(<MToastHost />);
|
||||
let first = 0;
|
||||
let second = 0;
|
||||
act(() => {
|
||||
first = window.__addToast?.('First') ?? 0;
|
||||
second = window.__addToast?.('Second') ?? 0;
|
||||
});
|
||||
|
||||
expect(second).toBe(first + 1);
|
||||
expect(screen.getByText('First')).toBeInTheDocument();
|
||||
expect(screen.getByText('Second')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MTOAST-004: auto-dismisses after its duration plus the exit animation', () => {
|
||||
vi.useFakeTimers();
|
||||
render(<MToastHost />);
|
||||
push('Trip saved', 'info', 1000);
|
||||
|
||||
act(() => { vi.advanceTimersByTime(1000); });
|
||||
// Still on screen, now playing the exit animation.
|
||||
expect(pill('Trip saved').className).toContain('m-toast-out');
|
||||
|
||||
act(() => { vi.advanceTimersByTime(220); });
|
||||
expect(screen.queryByText('Trip saved')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MTOAST-005: a sticky toast waits for a tap', () => {
|
||||
vi.useFakeTimers();
|
||||
render(<MToastHost />);
|
||||
push('Offline', 'warning', 0);
|
||||
|
||||
act(() => { vi.advanceTimersByTime(10000); });
|
||||
expect(screen.getByText('Offline')).toBeInTheDocument();
|
||||
expect(pill('Offline').className).toContain('pointer-events-auto');
|
||||
|
||||
fireEvent.click(pill('Offline'));
|
||||
act(() => { vi.advanceTimersByTime(220); });
|
||||
expect(screen.queryByText('Offline')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MTOAST-006: only the non-info types get a status dot', () => {
|
||||
render(<MToastHost />);
|
||||
push('Done', 'success');
|
||||
push('Heads up', 'info');
|
||||
|
||||
expect(pill('Done').querySelector('span[aria-hidden]')).toBeInTheDocument();
|
||||
expect(pill('Heads up').querySelector('span[aria-hidden]')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-MOB-MTOAST-007: pending timers are cleared when the host unmounts', () => {
|
||||
vi.useFakeTimers();
|
||||
const { unmount } = render(<MToastHost />);
|
||||
push('Trip saved', 'info', 1000);
|
||||
|
||||
unmount();
|
||||
// Without the cleanup the pending dismiss would setState on an unmounted host.
|
||||
expect(() => act(() => { vi.advanceTimersByTime(2000); })).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, act } from '../../helpers/render';
|
||||
import MobileShell from '../../../src/mobile/MobileShell';
|
||||
import { useAddonStore } from '../../../src/store/addonStore';
|
||||
import { usePluginStore } from '../../../src/store/pluginStore';
|
||||
|
||||
// FE-MOB-MSHELL-001 onwards
|
||||
|
||||
beforeEach(() => {
|
||||
useAddonStore.setState({ addons: [], loaded: true });
|
||||
usePluginStore.setState({ plugins: [], loaded: true });
|
||||
});
|
||||
|
||||
describe('MobileShell', () => {
|
||||
it('FE-MOB-MSHELL-001: keeps the legacy wrapper above the phone breakpoint', () => {
|
||||
const { container } = render(
|
||||
<MobileShell isPhone={false}><p>page body</p></MobileShell>,
|
||||
{ initialEntries: ['/dashboard'] },
|
||||
);
|
||||
|
||||
expect(screen.getByText('page body')).toBeInTheDocument();
|
||||
expect(container.querySelector('.m-root')).toBeNull();
|
||||
expect(document.getElementById('m-sheet-root')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-MOB-MSHELL-002: scopes the mobile tokens and mounts the dock plus sheet portal', () => {
|
||||
const { container } = render(
|
||||
<MobileShell isPhone><p>page body</p></MobileShell>,
|
||||
{ initialEntries: ['/dashboard'] },
|
||||
);
|
||||
|
||||
expect(container.querySelector('.m-root')).toBeInTheDocument();
|
||||
expect(document.getElementById('m-sheet-root')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'My Trips' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MSHELL-003: hides the global dock inside the trip planner', () => {
|
||||
render(
|
||||
<MobileShell isPhone><p>page body</p></MobileShell>,
|
||||
{ initialEntries: ['/trips/7'] },
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'My Trips' })).not.toBeInTheDocument();
|
||||
expect(screen.getByText('page body')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MSHELL-004: the mobile toast presenter takes over the global bridge', () => {
|
||||
const previous = vi.fn(() => 0);
|
||||
window.__addToast = previous;
|
||||
|
||||
render(<MobileShell isPhone><p>page body</p></MobileShell>, { initialEntries: ['/dashboard'] });
|
||||
expect(window.__addToast).not.toBe(previous);
|
||||
|
||||
act(() => { window.__addToast?.('Saved', 'success'); });
|
||||
expect(screen.getByText('Saved')).toBeInTheDocument();
|
||||
|
||||
delete window.__addToast;
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,14 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { fireEvent, render, screen, waitFor } from '../../../helpers/render';
|
||||
import { server } from '../../../helpers/msw/server';
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store';
|
||||
import { buildAdmin } from '../../../helpers/factories';
|
||||
import { useAuthStore } from '../../../../src/store/authStore';
|
||||
import { useAddonStore } from '../../../../src/store/addonStore';
|
||||
import { ToastContainer } from '../../../../src/components/shared/Toast';
|
||||
import MAdmin from '../../../../src/mobile/screens/admin/MAdmin';
|
||||
|
||||
// FE-MOB-ADMIN-001 onwards
|
||||
@@ -14,9 +19,99 @@ vi.mock('../../../../src/mobile/screens/admin/MAdminPermissionsPanel', () => ({
|
||||
default: () => <div data-testid="permissions-panel" />,
|
||||
}));
|
||||
|
||||
// Every other section panel has its own suite; here only the routing into them
|
||||
// matters, so they are reduced to markers.
|
||||
vi.mock('../../../../src/mobile/screens/admin/MAdminPluginsPanel', () => ({
|
||||
default: () => <div data-testid="plugins-panel" />,
|
||||
}));
|
||||
vi.mock('../../../../src/mobile/screens/admin/MAdminBackupPanel', () => ({
|
||||
default: () => <div data-testid="backup-panel" />,
|
||||
}));
|
||||
vi.mock('../../../../src/mobile/screens/admin/MAdminCategoryManager', () => ({
|
||||
default: () => <div data-testid="category-manager" />,
|
||||
}));
|
||||
vi.mock('../../../../src/mobile/screens/admin/MAdminPackingTemplateManager', () => ({
|
||||
default: () => <div data-testid="packing-templates" />,
|
||||
}));
|
||||
vi.mock('../../../../src/mobile/screens/admin/MAdminDefaultUserSettings', () => ({
|
||||
default: () => <div data-testid="default-user-settings" />,
|
||||
}));
|
||||
vi.mock('../../../../src/mobile/screens/admin/MAdminDevNotificationsPanel', () => ({
|
||||
default: () => <div data-testid="dev-notifications" />,
|
||||
}));
|
||||
vi.mock('../../../../src/mobile/screens/admin/MAdminNotificationsSection', () => ({
|
||||
default: () => <div data-testid="notifications-section" />,
|
||||
}));
|
||||
vi.mock('../../../../src/mobile/screens/admin/MAdminMcpTokensPanel', () => ({
|
||||
default: () => <div data-testid="mcp-tokens-panel" />,
|
||||
}));
|
||||
vi.mock('../../../../src/mobile/screens/admin/MAdminAuditLogPanel', () => ({
|
||||
default: ({ serverTimezone }: { serverTimezone?: string }) => (
|
||||
<div data-testid="audit-panel">{serverTimezone}</div>
|
||||
),
|
||||
}));
|
||||
vi.mock('../../../../src/mobile/screens/admin/MAdminGitHubPanel', () => ({
|
||||
default: ({ isPrerelease }: { isPrerelease: boolean }) => (
|
||||
<div data-testid="github-panel">{String(isPrerelease)}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// The addon manager is reduced to the two controls MAdmin wires itself.
|
||||
vi.mock('../../../../src/mobile/screens/admin/MAdminAddonManager', () => ({
|
||||
default: ({
|
||||
bagTrackingEnabled,
|
||||
onToggleBagTracking,
|
||||
collabFeatures,
|
||||
onToggleCollabFeature,
|
||||
}: {
|
||||
bagTrackingEnabled: boolean;
|
||||
onToggleBagTracking: () => void;
|
||||
collabFeatures: Record<string, boolean>;
|
||||
onToggleCollabFeature: (key: string) => void;
|
||||
}) => (
|
||||
<div>
|
||||
<span data-testid="bag-state">{String(bagTrackingEnabled)}</span>
|
||||
<button type="button" onClick={onToggleBagTracking}>toggle bag</button>
|
||||
<span data-testid="chat-state">{String(collabFeatures.chat)}</span>
|
||||
<button type="button" onClick={() => onToggleCollabFeature('chat')}>toggle chat</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
function renderAdmin() {
|
||||
return render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<Routes>
|
||||
<Route path="/admin" element={<MAdmin />} />
|
||||
<Route path="/dashboard" element={<div>dashboard page</div>} />
|
||||
</Routes>
|
||||
</>,
|
||||
{ initialEntries: ['/admin'] },
|
||||
);
|
||||
}
|
||||
|
||||
async function openSection(label: string) {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Administration' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: label }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
||||
server.use(
|
||||
http.get('/api/admin/collab-features', () =>
|
||||
HttpResponse.json({ chat: true, notes: true, polls: true, whatsnext: true }),
|
||||
),
|
||||
http.get('/api/admin/places-photos', () => HttpResponse.json({ enabled: false })),
|
||||
http.get('/api/admin/places-autocomplete', () => HttpResponse.json({ enabled: false })),
|
||||
http.get('/api/admin/places-details', () => HttpResponse.json({ enabled: false })),
|
||||
http.get('/api/admin/invites/trips', () => HttpResponse.json({ trips: [] })),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('MAdmin', () => {
|
||||
@@ -51,4 +146,227 @@ describe('MAdmin', () => {
|
||||
await waitFor(() => expect(screen.getByText('Authentication Methods')).toBeInTheDocument());
|
||||
expect(screen.queryByText('alice')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ADMIN-003: the back button leaves for the dashboard', async () => {
|
||||
renderAdmin();
|
||||
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
|
||||
|
||||
expect(screen.getByText('dashboard page')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ADMIN-004: the header plus opens the create-user sheet', async () => {
|
||||
renderAdmin();
|
||||
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create User' }));
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'Create User' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ADMIN-005: MCP Access and the dev section stay hidden unless enabled', async () => {
|
||||
renderAdmin();
|
||||
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Administration' }));
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'MCP Access' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Dev: Notifications' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ADMIN-006: the MCP addon adds its section to the dropdown', async () => {
|
||||
useAddonStore.setState({
|
||||
addons: [{ id: 'mcp', name: 'MCP', type: 'integration', icon: 'plug', enabled: true }],
|
||||
bagTracking: false,
|
||||
loaded: true,
|
||||
});
|
||||
renderAdmin();
|
||||
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
|
||||
|
||||
await openSection('MCP Access');
|
||||
|
||||
expect(screen.getByTestId('mcp-tokens-panel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ADMIN-007: dev mode adds the dev notifications section', async () => {
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin(), devMode: true });
|
||||
renderAdmin();
|
||||
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
|
||||
|
||||
await openSection('Dev: Notifications');
|
||||
|
||||
expect(screen.getByTestId('dev-notifications')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ADMIN-008: every remaining section is reachable from the dropdown', async () => {
|
||||
renderAdmin();
|
||||
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
|
||||
|
||||
await openSection('User Defaults');
|
||||
expect(screen.getByTestId('default-user-settings')).toBeInTheDocument();
|
||||
|
||||
await openSection('Personalization');
|
||||
expect(screen.getByTestId('packing-templates')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('category-manager')).toBeInTheDocument();
|
||||
|
||||
await openSection('Plugins');
|
||||
expect(screen.getByTestId('plugins-panel')).toBeInTheDocument();
|
||||
|
||||
await openSection('Notifications');
|
||||
expect(screen.getByTestId('notifications-section')).toBeInTheDocument();
|
||||
|
||||
await openSection('Backup');
|
||||
expect(screen.getByTestId('backup-panel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ADMIN-009: the audit and GitHub panels get the server timezone and prerelease flag', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/version-check', () =>
|
||||
HttpResponse.json({
|
||||
update_available: true,
|
||||
latest: '3.5.0',
|
||||
current: '3.4.0',
|
||||
is_prerelease: true,
|
||||
is_docker: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin(), serverTimezone: 'Europe/Berlin' });
|
||||
renderAdmin();
|
||||
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
|
||||
|
||||
await openSection('Audit');
|
||||
expect(screen.getByTestId('audit-panel')).toHaveTextContent('Europe/Berlin');
|
||||
|
||||
await openSection('GitHub');
|
||||
await waitFor(() => expect(screen.getByTestId('github-panel')).toHaveTextContent('true'));
|
||||
});
|
||||
|
||||
it('FE-MOB-ADMIN-010: the update banner opens the how-to-update sheet', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/version-check', () =>
|
||||
HttpResponse.json({ update_available: true, latest: '3.5.0', current: '3.4.0', is_docker: true }),
|
||||
),
|
||||
);
|
||||
renderAdmin();
|
||||
|
||||
await screen.findByText('Update available');
|
||||
expect(screen.getByText('Version v3.5.0 available · you have v3.4.0')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Update' }));
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'How to Update' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ADMIN-011: no banner when the version check reports no update', async () => {
|
||||
renderAdmin();
|
||||
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
|
||||
|
||||
expect(screen.queryByText('Update available')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ADMIN-012: demo instances can save a baseline', async () => {
|
||||
let saved = false;
|
||||
server.use(
|
||||
http.post('/api/admin/save-demo-baseline', () => {
|
||||
saved = true;
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin(), demoMode: true });
|
||||
renderAdmin();
|
||||
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save Baseline' }));
|
||||
|
||||
await screen.findByText('Baseline saved! Resets will restore to this state.');
|
||||
expect(saved).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-MOB-ADMIN-013: a failing baseline save surfaces the server message', async () => {
|
||||
server.use(
|
||||
http.post('/api/admin/save-demo-baseline', () =>
|
||||
HttpResponse.json({ error: 'Not a demo instance' }, { status: 400 }),
|
||||
),
|
||||
);
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin(), demoMode: true });
|
||||
renderAdmin();
|
||||
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save Baseline' }));
|
||||
|
||||
await screen.findByText('Not a demo instance');
|
||||
});
|
||||
|
||||
it('FE-MOB-ADMIN-014: the bag tracking toggle persists and rolls back on failure', async () => {
|
||||
const bodies: unknown[] = [];
|
||||
server.use(
|
||||
http.put('/api/admin/bag-tracking', async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
return bodies.length === 1
|
||||
? HttpResponse.json({ enabled: true })
|
||||
: HttpResponse.json({ error: 'boom' }, { status: 500 });
|
||||
}),
|
||||
);
|
||||
renderAdmin();
|
||||
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
|
||||
await openSection('Addons');
|
||||
|
||||
expect(screen.getByTestId('bag-state')).toHaveTextContent('false');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'toggle bag' }));
|
||||
await waitFor(() => expect(screen.getByTestId('bag-state')).toHaveTextContent('true'));
|
||||
|
||||
// Second call fails → the optimistic value is reverted
|
||||
fireEvent.click(screen.getByRole('button', { name: 'toggle bag' }));
|
||||
await waitFor(() => expect(screen.getByTestId('bag-state')).toHaveTextContent('true'));
|
||||
expect(bodies).toEqual([{ enabled: true }, { enabled: false }]);
|
||||
});
|
||||
|
||||
it('FE-MOB-ADMIN-015: a collab feature toggle sends only the changed key', async () => {
|
||||
let sent: Record<string, unknown> = {};
|
||||
server.use(
|
||||
http.put('/api/admin/collab-features', async ({ request }) => {
|
||||
sent = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({ ...sent });
|
||||
}),
|
||||
);
|
||||
renderAdmin();
|
||||
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
|
||||
await openSection('Addons');
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('chat-state')).toHaveTextContent('true'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'toggle chat' }));
|
||||
|
||||
await waitFor(() => expect(sent).toEqual({ chat: false }));
|
||||
expect(screen.getByTestId('chat-state')).toHaveTextContent('false');
|
||||
});
|
||||
|
||||
it('FE-MOB-ADMIN-016: a failing collab update restores the previous flags', async () => {
|
||||
server.use(
|
||||
http.put('/api/admin/collab-features', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
|
||||
);
|
||||
renderAdmin();
|
||||
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
|
||||
await openSection('Addons');
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('chat-state')).toHaveTextContent('true'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'toggle chat' }));
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('chat-state')).toHaveTextContent('true'));
|
||||
});
|
||||
|
||||
it('FE-MOB-ADMIN-017: the stats grid falls back to zero files', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/stats', () =>
|
||||
HttpResponse.json({ totalUsers: 3, totalTrips: 7, totalPlaces: 11 }),
|
||||
),
|
||||
);
|
||||
renderAdmin();
|
||||
|
||||
await screen.findByText('11');
|
||||
expect(screen.getByText('3')).toBeInTheDocument();
|
||||
expect(screen.getByText('7')).toBeInTheDocument();
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
// FE-MOB-AADD-001 to FE-MOB-AADD-023
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { delay, http, HttpResponse } from 'msw';
|
||||
import { render, screen, waitFor, within } from '../../../helpers/render';
|
||||
import { server } from '../../../helpers/msw/server';
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store';
|
||||
import { buildSettings } from '../../../helpers/factories';
|
||||
import { useSettingsStore } from '../../../../src/store/settingsStore';
|
||||
import { useAddonStore } from '../../../../src/store/addonStore';
|
||||
import { ToastContainer } from '../../../../src/components/shared/Toast';
|
||||
import MAdminAddonManager from '../../../../src/mobile/screens/admin/MAdminAddonManager';
|
||||
|
||||
interface AddonFixture {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
type: string;
|
||||
enabled: boolean;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function buildAddon(overrides: Partial<AddonFixture> = {}): AddonFixture {
|
||||
return {
|
||||
id: 'todo',
|
||||
name: 'Todo List',
|
||||
description: 'Track tasks',
|
||||
icon: 'ListChecks',
|
||||
type: 'trip',
|
||||
enabled: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function addonsRoute(addons: AddonFixture[]) {
|
||||
return http.get('/api/admin/addons', () => HttpResponse.json({ addons }));
|
||||
}
|
||||
|
||||
function llmAddon(config: Record<string, unknown> = {}): AddonFixture {
|
||||
return buildAddon({
|
||||
id: 'llm_parsing',
|
||||
name: 'AI Parsing',
|
||||
description: 'Extract bookings from files',
|
||||
icon: 'Sparkles',
|
||||
type: 'integration',
|
||||
enabled: true,
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
function modelsRoute(names: string[], seen?: (string | null)[]) {
|
||||
return http.get('/api/admin/llm/local/models', ({ request }) => {
|
||||
seen?.push(new URL(request.url).searchParams.get('baseUrl'));
|
||||
return HttpResponse.json({ models: names.map(name => ({ name, size: 1 })) });
|
||||
});
|
||||
}
|
||||
|
||||
let loadAddonsSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: false }) });
|
||||
loadAddonsSpy = vi.spyOn(useAddonStore.getState(), 'loadAddons').mockResolvedValue(undefined);
|
||||
server.use(addonsRoute([]));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('MAdminAddonManager', () => {
|
||||
it('FE-MOB-AADD-001: shows the spinner while addons load', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/addons', async () => {
|
||||
await delay(60);
|
||||
return HttpResponse.json({ addons: [] });
|
||||
}),
|
||||
);
|
||||
render(<MAdminAddonManager />);
|
||||
|
||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
await screen.findByText('No addons available');
|
||||
expect(document.querySelector('.animate-spin')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AADD-002: the header uses the light wordmark in light mode', async () => {
|
||||
render(<MAdminAddonManager />);
|
||||
|
||||
await screen.findByText('No addons available');
|
||||
expect(screen.getByText('Addons')).toBeInTheDocument();
|
||||
expect(screen.getByAltText('TREK')).toHaveAttribute('src', '/text-dark.svg');
|
||||
});
|
||||
|
||||
it('FE-MOB-AADD-003: dark mode and auto+prefers-dark swap the wordmark', async () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: 'dark' }) });
|
||||
const { unmount } = render(<MAdminAddonManager />);
|
||||
await screen.findByText('No addons available');
|
||||
expect(screen.getByAltText('TREK')).toHaveAttribute('src', '/text-light.svg');
|
||||
unmount();
|
||||
|
||||
const matchMedia = vi.fn().mockReturnValue({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() });
|
||||
vi.stubGlobal('matchMedia', matchMedia);
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: 'auto' }) });
|
||||
render(<MAdminAddonManager />);
|
||||
await screen.findByText('No addons available');
|
||||
expect(screen.getByAltText('TREK')).toHaveAttribute('src', '/text-light.svg');
|
||||
expect(matchMedia).toHaveBeenCalledWith('(prefers-color-scheme: dark)');
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('FE-MOB-AADD-004: a failing load toasts the addon error', async () => {
|
||||
server.use(http.get('/api/admin/addons', () => HttpResponse.error()));
|
||||
render(<><ToastContainer /><MAdminAddonManager /></>);
|
||||
|
||||
await screen.findByText('Failed to update addon');
|
||||
expect(screen.getByText('No addons available')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AADD-005: trip addons render with the section band, catalog label and type badge', async () => {
|
||||
server.use(addonsRoute([buildAddon({ id: 'packing', name: 'Packing', enabled: true })]));
|
||||
render(<MAdminAddonManager />);
|
||||
|
||||
await screen.findByText('Lists');
|
||||
expect(screen.getByText('Trip — Available as a tab within each trip')).toBeInTheDocument();
|
||||
expect(screen.getByText('Packing lists and to-do tasks for your trips')).toBeInTheDocument();
|
||||
expect(screen.getByText('Trip')).toBeInTheDocument();
|
||||
expect(screen.getByRole('switch', { name: 'Lists' })).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
it('FE-MOB-AADD-006: an addon without a catalog entry falls back to the API name', async () => {
|
||||
server.use(addonsRoute([buildAddon({ id: 'mystery', name: 'Mystery Addon', description: 'No catalog key', icon: 'NoSuchIcon' })]));
|
||||
render(<MAdminAddonManager />);
|
||||
|
||||
await screen.findByText('Mystery Addon');
|
||||
expect(screen.getByText('No catalog key')).toBeInTheDocument();
|
||||
expect(screen.getByRole('switch', { name: 'Mystery Addon' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AADD-007: photo-flavoured trip addons are hidden from the trip section', async () => {
|
||||
server.use(
|
||||
addonsRoute([
|
||||
buildAddon({ id: 'photos', name: 'Memories', icon: 'Image' }),
|
||||
buildAddon({ id: 'gallery', name: 'Trip Photos', icon: 'Puzzle', description: 'Share your photo stream' }),
|
||||
buildAddon({ id: 'todo', name: 'Todo List' }),
|
||||
]),
|
||||
);
|
||||
render(<MAdminAddonManager />);
|
||||
|
||||
await screen.findByText('Todo List');
|
||||
expect(screen.queryByText('Memories')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Trip Photos')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AADD-008: toggling an addon persists it, refreshes the store and toasts', async () => {
|
||||
const user = userEvent.setup();
|
||||
let body: unknown = null;
|
||||
server.use(
|
||||
addonsRoute([buildAddon({ id: 'todo', enabled: false })]),
|
||||
http.put('/api/admin/addons/todo', async ({ request }) => {
|
||||
body = await request.json();
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><MAdminAddonManager /></>);
|
||||
await screen.findByText('Todo List');
|
||||
|
||||
const toggle = screen.getByRole('switch', { name: 'Todo List' });
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'false');
|
||||
await user.click(toggle);
|
||||
|
||||
await waitFor(() => expect(body).toEqual({ enabled: true }));
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'true');
|
||||
expect(loadAddonsSpy).toHaveBeenCalled();
|
||||
await screen.findByText('Addon updated');
|
||||
});
|
||||
|
||||
it('FE-MOB-AADD-009: a failing toggle rolls the switch back', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
addonsRoute([buildAddon({ id: 'todo', enabled: true })]),
|
||||
http.put('/api/admin/addons/todo', () => HttpResponse.error()),
|
||||
);
|
||||
render(<><ToastContainer /><MAdminAddonManager /></>);
|
||||
await screen.findByText('Todo List');
|
||||
|
||||
const toggle = screen.getByRole('switch', { name: 'Todo List' });
|
||||
await user.click(toggle);
|
||||
|
||||
await screen.findByText('Failed to update addon');
|
||||
await waitFor(() => expect(toggle).toHaveAttribute('aria-checked', 'true'));
|
||||
expect(loadAddonsSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-AADD-010: bag tracking shows under an enabled packing addon and reports clicks', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggleBagTracking = vi.fn();
|
||||
server.use(addonsRoute([buildAddon({ id: 'packing', enabled: true })]));
|
||||
render(<MAdminAddonManager bagTrackingEnabled={false} onToggleBagTracking={onToggleBagTracking} />);
|
||||
|
||||
await screen.findByText('Bag Tracking');
|
||||
expect(screen.getByText('Enable weight and bag assignment for packing items')).toBeInTheDocument();
|
||||
const bagToggle = screen.getByRole('switch', { name: 'Bag Tracking' });
|
||||
expect(bagToggle).toHaveAttribute('aria-checked', 'false');
|
||||
|
||||
await user.click(bagToggle);
|
||||
expect(onToggleBagTracking).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-AADD-011: bag tracking stays hidden when packing is off or the handler is missing', async () => {
|
||||
server.use(addonsRoute([buildAddon({ id: 'packing', enabled: false })]));
|
||||
const { unmount } = render(<MAdminAddonManager bagTrackingEnabled onToggleBagTracking={vi.fn()} />);
|
||||
await screen.findByText('Lists');
|
||||
expect(screen.queryByText('Bag Tracking')).not.toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
server.use(addonsRoute([buildAddon({ id: 'packing', enabled: true })]));
|
||||
render(<MAdminAddonManager bagTrackingEnabled />);
|
||||
await screen.findByText('Lists');
|
||||
expect(screen.queryByText('Bag Tracking')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AADD-012: the collab sub-features render and report the toggled key', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggleCollabFeature = vi.fn();
|
||||
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
|
||||
render(
|
||||
<MAdminAddonManager
|
||||
collabFeatures={{ chat: true, notes: false, polls: false, whatsnext: true }}
|
||||
onToggleCollabFeature={onToggleCollabFeature}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByText('Chat');
|
||||
expect(screen.getByText('Notes')).toBeInTheDocument();
|
||||
expect(screen.getByText('Polls')).toBeInTheDocument();
|
||||
expect(screen.getByText("What's Next")).toBeInTheDocument();
|
||||
expect(screen.getByRole('switch', { name: 'Chat' })).toHaveAttribute('aria-checked', 'true');
|
||||
expect(screen.getByRole('switch', { name: 'Notes' })).toHaveAttribute('aria-checked', 'false');
|
||||
|
||||
await user.click(screen.getByRole('switch', { name: 'Polls' }));
|
||||
expect(onToggleCollabFeature).toHaveBeenCalledWith('polls');
|
||||
});
|
||||
|
||||
it('FE-MOB-AADD-013: collab sub-features stay hidden without the handler props', async () => {
|
||||
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
|
||||
render(<MAdminAddonManager />);
|
||||
|
||||
await screen.findByText('Collab');
|
||||
expect(screen.queryByText('Polls')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AADD-014: photo providers render as sub-rows under the journey addon', async () => {
|
||||
server.use(
|
||||
addonsRoute([
|
||||
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }),
|
||||
buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: true }),
|
||||
buildAddon({ id: 'synologyphotos', name: 'Synology Photos', description: 'NAS photos', type: 'photo_provider', enabled: false }),
|
||||
buildAddon({ id: 'unsplash', name: 'Unsplash', description: 'Stock photos', type: 'photo_provider', enabled: false }),
|
||||
]),
|
||||
);
|
||||
render(<MAdminAddonManager />);
|
||||
|
||||
await screen.findByText('Immich');
|
||||
expect(screen.getByText('Global — Available as a standalone section in the main navigation')).toBeInTheDocument();
|
||||
expect(screen.getByText('Synology Photos')).toBeInTheDocument();
|
||||
expect(screen.getByText('Unsplash')).toBeInTheDocument();
|
||||
expect(screen.getByRole('switch', { name: 'Immich' })).toHaveAttribute('aria-checked', 'true');
|
||||
// Journey + three providers, and providers are not shown as top-level rows.
|
||||
expect(screen.getAllByRole('switch')).toHaveLength(4);
|
||||
expect(screen.queryByText('Global')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AADD-015: toggling a provider persists it and rolls back on failure', async () => {
|
||||
const user = userEvent.setup();
|
||||
let body: unknown = null;
|
||||
server.use(
|
||||
addonsRoute([
|
||||
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }),
|
||||
buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: false }),
|
||||
buildAddon({ id: 'unsplash', name: 'Unsplash', description: 'Stock photos', type: 'photo_provider', enabled: false }),
|
||||
]),
|
||||
http.put('/api/admin/addons/immich', async ({ request }) => {
|
||||
body = await request.json();
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
http.put('/api/admin/addons/unsplash', () => HttpResponse.error()),
|
||||
);
|
||||
render(<><ToastContainer /><MAdminAddonManager /></>);
|
||||
await screen.findByText('Immich');
|
||||
|
||||
await user.click(screen.getByRole('switch', { name: 'Immich' }));
|
||||
await waitFor(() => expect(body).toEqual({ enabled: true }));
|
||||
expect(screen.getByRole('switch', { name: 'Immich' })).toHaveAttribute('aria-checked', 'true');
|
||||
await screen.findByText('Addon updated');
|
||||
|
||||
await user.click(screen.getByRole('switch', { name: 'Unsplash' }));
|
||||
await screen.findByText('Failed to update addon');
|
||||
await waitFor(() => expect(screen.getByRole('switch', { name: 'Unsplash' })).toHaveAttribute('aria-checked', 'false'));
|
||||
});
|
||||
|
||||
it('FE-MOB-AADD-016: a disabled AI-parsing addon renders the integration row without its config', async () => {
|
||||
server.use(addonsRoute([llmAddon({ provider: 'local' })].map(a => ({ ...a, enabled: false }))));
|
||||
render(<MAdminAddonManager />);
|
||||
|
||||
await screen.findByText('AI Parsing');
|
||||
expect(screen.getByText('Integration — Backend services and API integrations with no dedicated page')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Connection')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AADD-017: the local provider lists installed models and selecting a chip fills the model field', async () => {
|
||||
const user = userEvent.setup();
|
||||
const urls: (string | null)[] = [];
|
||||
server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute(['qwen3:8b', 'llama3:8b'], urls));
|
||||
render(<MAdminAddonManager />);
|
||||
|
||||
await screen.findByText('Installed on the server');
|
||||
await screen.findByRole('button', { name: 'llama3:8b' });
|
||||
expect(urls[0]).toBe('http://localhost:11434/v1');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'llama3:8b' }));
|
||||
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('llama3:8b');
|
||||
|
||||
// qwen3:8b is installed, so the recommended row offers "Use" instead of "Pull".
|
||||
await user.click(screen.getByRole('button', { name: 'Use' }));
|
||||
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3:8b');
|
||||
expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-MOB-AADD-018: an unreachable Ollama shows the error, Refresh retries', async () => {
|
||||
const user = userEvent.setup();
|
||||
let calls = 0;
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local' })]),
|
||||
http.get('/api/admin/llm/local/models', () => {
|
||||
calls += 1;
|
||||
return calls === 1
|
||||
? HttpResponse.json({ error: 'down' }, { status: 500 })
|
||||
: HttpResponse.json({ models: [] });
|
||||
}),
|
||||
);
|
||||
render(<MAdminAddonManager />);
|
||||
|
||||
await screen.findByText(/Request failed with status code 500/);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Refresh' }));
|
||||
await screen.findByText('No models installed yet — pull one below.');
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
it('FE-MOB-AADD-019: the base URL is used for model lookups on blur', async () => {
|
||||
const user = userEvent.setup();
|
||||
const urls: (string | null)[] = [];
|
||||
server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute([], urls));
|
||||
render(<MAdminAddonManager />);
|
||||
|
||||
await screen.findByText('Installed on the server');
|
||||
const baseUrl = screen.getByPlaceholderText('http://localhost:11434/v1');
|
||||
await user.type(baseUrl, 'http://ollama.lan:11434/v1');
|
||||
await user.tab();
|
||||
|
||||
await waitFor(() => expect(urls).toContain('http://ollama.lan:11434/v1'));
|
||||
});
|
||||
|
||||
it('FE-MOB-AADD-020: switching providers swaps the base URL field and the model hint', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(addonsRoute([llmAddon({ provider: 'local', apiKey: '••••••••' })]), modelsRoute([]));
|
||||
render(<MAdminAddonManager />);
|
||||
|
||||
await screen.findByText('Installed on the server');
|
||||
expect(screen.getByPlaceholderText('••••••••')).toBeInTheDocument();
|
||||
|
||||
await user.type(screen.getByPlaceholderText('select or pull below'), 'mistral:7b');
|
||||
expect(screen.getByDisplayValue('mistral:7b')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
|
||||
expect(screen.getByPlaceholderText('https://api.openai.com/v1')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('gpt-4o')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Anthropic' }));
|
||||
expect(screen.queryByPlaceholderText('https://api.openai.com/v1')).not.toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('claude-opus-4-8')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Anthropic reads PDFs/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AADD-021: pulling a model streams progress, then selects it', async () => {
|
||||
const user = userEvent.setup();
|
||||
let pulled: unknown = null;
|
||||
let modelCalls = 0;
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local' })]),
|
||||
http.get('/api/admin/llm/local/models', () => {
|
||||
modelCalls += 1;
|
||||
return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3:8b', size: 1 }] });
|
||||
}),
|
||||
http.post('/api/admin/llm/local/pull', async ({ request }) => {
|
||||
pulled = await request.json();
|
||||
await delay(150);
|
||||
return new HttpResponse(
|
||||
'{"status":"pulling manifest"}\n{"status":"downloading","total":100,"completed":40}\nnot-json\n',
|
||||
{ headers: { 'Content-Type': 'application/x-ndjson' } },
|
||||
);
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><MAdminAddonManager /></>);
|
||||
|
||||
await screen.findByText('No models installed yet — pull one below.');
|
||||
await user.click(screen.getByRole('button', { name: 'Pull' }));
|
||||
|
||||
// While the request is in flight the row shows the busy label and status line.
|
||||
await screen.findByText('Pulling…');
|
||||
expect(screen.getByText('starting…')).toBeInTheDocument();
|
||||
|
||||
await screen.findByText('Model pulled');
|
||||
expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3:8b' });
|
||||
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3:8b');
|
||||
// Reloaded models now contain the pulled one, so the row switches to "Selected".
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled());
|
||||
});
|
||||
|
||||
it('FE-MOB-AADD-022: a failing pull surfaces the server error', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local' })]),
|
||||
modelsRoute([]),
|
||||
http.post('/api/admin/llm/local/pull', () => HttpResponse.json({ error: 'no disk space' }, { status: 500 })),
|
||||
);
|
||||
render(<><ToastContainer /><MAdminAddonManager /></>);
|
||||
|
||||
await screen.findByText('No models installed yet — pull one below.');
|
||||
await user.click(screen.getByRole('button', { name: 'Pull' }));
|
||||
|
||||
await screen.findByText('no disk space');
|
||||
expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('FE-MOB-AADD-023: saving posts the whole config and reports both outcomes', async () => {
|
||||
const user = userEvent.setup();
|
||||
const bodies: unknown[] = [];
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'openai', model: 'gpt-4o', baseUrl: 'https://api.openai.com/v1', apiKey: '••••••••', multimodal: true })]),
|
||||
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
return bodies.length === 1 ? HttpResponse.json({ success: true }) : HttpResponse.error();
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><MAdminAddonManager /></>);
|
||||
|
||||
const save = await screen.findByRole('button', { name: 'Save' });
|
||||
await user.click(save);
|
||||
|
||||
await screen.findByText('Saved');
|
||||
expect(bodies[0]).toEqual({
|
||||
config: {
|
||||
provider: 'openai',
|
||||
model: 'gpt-4o',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
apiKey: '••••••••',
|
||||
multimodal: true,
|
||||
},
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await screen.findByText('Failed to save');
|
||||
});
|
||||
|
||||
it('FE-MOB-AADD-024: the API key field can be revealed', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(addonsRoute([llmAddon({ provider: 'anthropic', apiKey: 'sk-secret' })]));
|
||||
render(<MAdminAddonManager />);
|
||||
|
||||
const field = await screen.findByDisplayValue('sk-secret');
|
||||
expect(field).toHaveAttribute('type', 'password');
|
||||
|
||||
await user.click(within(field.parentElement as HTMLElement).getByRole('button', { name: 'Show or hide' }));
|
||||
expect(screen.getByDisplayValue('sk-secret')).toHaveAttribute('type', 'text');
|
||||
|
||||
await user.type(screen.getByDisplayValue('sk-secret'), '-rotated');
|
||||
expect(screen.getByDisplayValue('sk-secret-rotated')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
// FE-MOB-AUDIT-001 to FE-MOB-AUDIT-012
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { render, screen, waitFor } from '../../../helpers/render';
|
||||
import { server } from '../../../helpers/msw/server';
|
||||
import { resetAllStores } from '../../../helpers/store';
|
||||
import MAdminAuditLogPanel from '../../../../src/mobile/screens/admin/MAdminAuditLogPanel';
|
||||
|
||||
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: null,
|
||||
details: null,
|
||||
ip: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('MAdminAuditLogPanel', () => {
|
||||
it('FE-MOB-AUDIT-001: header, subtitle and the loading placeholder render on mount', () => {
|
||||
server.use(
|
||||
http.get('/api/admin/audit-log', async () => {
|
||||
await new Promise(() => {});
|
||||
return HttpResponse.json({ entries: [], total: 0 });
|
||||
}),
|
||||
);
|
||||
render(<MAdminAuditLogPanel serverTimezone="UTC" />);
|
||||
|
||||
expect(screen.getByText('Audit')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Security-sensitive and administration events (backups, users, MFA, settings).'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Refresh' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-MOB-AUDIT-002: empty state and a zeroed counter when there is nothing to show', async () => {
|
||||
server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [], total: 0 })));
|
||||
render(<MAdminAuditLogPanel serverTimezone="UTC" />);
|
||||
|
||||
await screen.findByText('No audit entries yet.');
|
||||
expect(screen.getByText('0 loaded · 0 total')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Load more')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AUDIT-003: an entry card shows action, user, resource, ip and details', async () => {
|
||||
server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [ENTRY_1], total: 1 })));
|
||||
render(<MAdminAuditLogPanel serverTimezone="UTC" />);
|
||||
|
||||
await screen.findByText('trip.create');
|
||||
expect(screen.getByText('User')).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();
|
||||
expect(screen.getByText('1 loaded · 1 total')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AUDIT-004: the timestamp is rendered in the server timezone', async () => {
|
||||
server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [ENTRY_1], total: 1 })));
|
||||
render(<MAdminAuditLogPanel serverTimezone="UTC" />);
|
||||
|
||||
await screen.findByText('trip.create');
|
||||
expect(screen.getByText('6/1/25, 10:30:00 AM')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AUDIT-005: timestamps without a Z suffix are treated as UTC', async () => {
|
||||
const naive = { ...ENTRY_1, created_at: '2025-06-01T10:30:00' };
|
||||
server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [naive], total: 1 })));
|
||||
render(<MAdminAuditLogPanel serverTimezone="UTC" />);
|
||||
|
||||
await screen.findByText('trip.create');
|
||||
expect(screen.getByText('6/1/25, 10:30:00 AM')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AUDIT-006: an unusable timezone falls back to the raw timestamp', async () => {
|
||||
server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [ENTRY_1], total: 1 })));
|
||||
render(<MAdminAuditLogPanel serverTimezone="Not/AZone" />);
|
||||
|
||||
await screen.findByText('trip.create');
|
||||
expect(screen.getByText('2025-06-01T10:30:00Z')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AUDIT-007: the user label falls back through email, id and dash', async () => {
|
||||
const entries = [
|
||||
{ ...ENTRY_1, id: 10, action: 'a.username' },
|
||||
{ ...ENTRY_1, id: 11, username: null, 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(<MAdminAuditLogPanel serverTimezone="UTC" />);
|
||||
|
||||
await screen.findByText('a.username');
|
||||
expect(screen.getByText('alice')).toBeInTheDocument();
|
||||
expect(screen.getByText('alice@example.com')).toBeInTheDocument();
|
||||
expect(screen.getByText('#7')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('—')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-AUDIT-008: null resource, ip and details render as a dash', async () => {
|
||||
const emptyDetails = { ...ENTRY_1, id: 21, action: 'a.emptyobj', details: {} };
|
||||
server.use(
|
||||
http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [ENTRY_2, emptyDetails], total: 2 })),
|
||||
);
|
||||
render(<MAdminAuditLogPanel serverTimezone="UTC" />);
|
||||
|
||||
await screen.findByText('trip.delete');
|
||||
// ENTRY_2 contributes three dashes, the empty details object a fourth
|
||||
expect(screen.getAllByText('—')).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('FE-MOB-AUDIT-009: Load more appends the next page and disappears when complete', async () => {
|
||||
const user = userEvent.setup();
|
||||
const offsets: string[] = [];
|
||||
server.use(
|
||||
http.get('/api/admin/audit-log', ({ request }) => {
|
||||
const offset = new URL(request.url).searchParams.get('offset') ?? '';
|
||||
offsets.push(offset);
|
||||
return offset === '0'
|
||||
? HttpResponse.json({ entries: [ENTRY_1], total: 2 })
|
||||
: HttpResponse.json({ entries: [ENTRY_2], total: 2 });
|
||||
}),
|
||||
);
|
||||
render(<MAdminAuditLogPanel serverTimezone="UTC" />);
|
||||
|
||||
await screen.findByText('trip.create');
|
||||
await user.click(screen.getByRole('button', { name: 'Load more' }));
|
||||
|
||||
await screen.findByText('trip.delete');
|
||||
expect(screen.getByText('trip.create')).toBeInTheDocument();
|
||||
expect(screen.getByText('2 loaded · 2 total')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument();
|
||||
expect(offsets).toEqual(['0', '100']);
|
||||
});
|
||||
|
||||
it('FE-MOB-AUDIT-010: a failing Load more keeps the entries already loaded', async () => {
|
||||
const user = userEvent.setup();
|
||||
let call = 0;
|
||||
server.use(
|
||||
http.get('/api/admin/audit-log', () => {
|
||||
call += 1;
|
||||
return call === 1
|
||||
? HttpResponse.json({ entries: [ENTRY_1], total: 5 })
|
||||
: HttpResponse.json({ error: 'boom' }, { status: 500 });
|
||||
}),
|
||||
);
|
||||
render(<MAdminAuditLogPanel serverTimezone="UTC" />);
|
||||
|
||||
await screen.findByText('trip.create');
|
||||
await user.click(screen.getByRole('button', { name: 'Load more' }));
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Load more' })).not.toBeDisabled());
|
||||
expect(screen.getByText('trip.create')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 loaded · 5 total')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AUDIT-011: Refresh throws away the appended pages', async () => {
|
||||
const user = userEvent.setup();
|
||||
let call = 0;
|
||||
server.use(
|
||||
http.get('/api/admin/audit-log', () => {
|
||||
call += 1;
|
||||
if (call === 1) return HttpResponse.json({ entries: [ENTRY_1], total: 2 });
|
||||
if (call === 2) return HttpResponse.json({ entries: [ENTRY_2], total: 2 });
|
||||
return HttpResponse.json({ entries: [{ ...ENTRY_1, id: 99, action: 'after.refresh' }], total: 1 });
|
||||
}),
|
||||
);
|
||||
render(<MAdminAuditLogPanel serverTimezone="UTC" />);
|
||||
|
||||
await screen.findByText('trip.create');
|
||||
await user.click(screen.getByRole('button', { name: 'Load more' }));
|
||||
await screen.findByText('trip.delete');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Refresh' }));
|
||||
|
||||
await screen.findByText('after.refresh');
|
||||
expect(screen.queryByText('trip.create')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('trip.delete')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('1 loaded · 1 total')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AUDIT-012: a failing first page falls back to the empty state', async () => {
|
||||
server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ error: 'boom' }, { status: 500 })));
|
||||
render(<MAdminAuditLogPanel />);
|
||||
|
||||
await screen.findByText('No audit entries yet.');
|
||||
expect(screen.getByText('0 loaded · 0 total')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,455 @@
|
||||
// FE-MOB-MBKP-001 to FE-MOB-MBKP-026
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { render, screen, waitFor, within, fireEvent } from '../../../helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../helpers/msw/server';
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store';
|
||||
import { buildSettings } from '../../../helpers/factories';
|
||||
import { useSettingsStore } from '../../../../src/store/settingsStore';
|
||||
import { ToastContainer } from '../../../../src/components/shared/Toast';
|
||||
import MAdminBackupPanel from '../../../../src/mobile/screens/admin/MAdminBackupPanel';
|
||||
|
||||
const manualBackup = { filename: 'backup-2025-01-15.zip', created_at: '2025-01-15T10:00:00Z', size: 2_048_000 };
|
||||
const autoBackup = { filename: 'auto-backup-2025-02-01.zip', created_at: '2025-02-01T02:00:00Z', size: 512_000 };
|
||||
|
||||
const DEFAULT_AUTO = { enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 };
|
||||
|
||||
function autoSettingsHandler(settings: Record<string, unknown> = DEFAULT_AUTO, timezone: string | null = 'UTC') {
|
||||
return http.get('/api/backup/auto-settings', () =>
|
||||
HttpResponse.json(timezone === null ? { settings } : { settings, timezone }),
|
||||
);
|
||||
}
|
||||
|
||||
function listHandler(backups: unknown[]) {
|
||||
return http.get('/api/backup/list', () => HttpResponse.json({ backups }));
|
||||
}
|
||||
|
||||
function withToast() {
|
||||
return render(<><ToastContainer /><MAdminBackupPanel /></>);
|
||||
}
|
||||
|
||||
function fileInput(): HTMLInputElement {
|
||||
return document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
}
|
||||
|
||||
/** Stub window.location so the post-restore reload cannot navigate jsdom. */
|
||||
function stubReload() {
|
||||
const reload = vi.fn();
|
||||
vi.stubGlobal('location', { ...window.location, reload });
|
||||
return reload;
|
||||
}
|
||||
|
||||
describe('MAdminBackupPanel', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ time_format: '24h' }) });
|
||||
server.use(listHandler([manualBackup]), autoSettingsHandler());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('FE-MOB-MBKP-001: renders the backup card with filename and formatted size', async () => {
|
||||
render(<MAdminBackupPanel />);
|
||||
|
||||
expect(await screen.findByText('backup-2025-01-15.zip')).toBeInTheDocument();
|
||||
expect(screen.getByText('Data Backup')).toBeInTheDocument();
|
||||
expect(screen.getByText('Database and all uploaded files')).toBeInTheDocument();
|
||||
expect(screen.getByText('2.0 MB')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Auto')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MBKP-002: sub-megabyte sizes are shown in KB and missing metadata as a dash', async () => {
|
||||
server.use(listHandler([
|
||||
{ filename: 'small.zip', created_at: '2025-03-01T08:00:00Z', size: 4096 },
|
||||
{ filename: 'unknown.zip', created_at: null, size: 0 },
|
||||
]));
|
||||
render(<MAdminBackupPanel />);
|
||||
|
||||
expect(await screen.findByText('4.0 KB')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('-')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-MOB-MBKP-003: files named auto-backup-* get the Auto badge', async () => {
|
||||
server.use(listHandler([autoBackup]));
|
||||
render(<MAdminBackupPanel />);
|
||||
|
||||
expect(await screen.findByText('auto-backup-2025-02-01.zip')).toBeInTheDocument();
|
||||
expect(screen.getByText('Auto')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MBKP-004: shows the loading row first and the empty state when nothing is stored', async () => {
|
||||
server.use(listHandler([]));
|
||||
render(<MAdminBackupPanel />);
|
||||
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
|
||||
expect(await screen.findByText('No backups yet')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Create first backup' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MBKP-005: a failing list request shows the load error toast', async () => {
|
||||
server.use(http.get('/api/backup/list', () => HttpResponse.json({ error: 'boom' }, { status: 500 })));
|
||||
withToast();
|
||||
|
||||
expect(await screen.findByText('Failed to load backups')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MBKP-006: the refresh button re-reads the list', async () => {
|
||||
const user = userEvent.setup();
|
||||
let calls = 0;
|
||||
server.use(http.get('/api/backup/list', () => {
|
||||
calls += 1;
|
||||
return HttpResponse.json({ backups: calls === 1 ? [manualBackup] : [manualBackup, autoBackup] });
|
||||
}));
|
||||
render(<MAdminBackupPanel />);
|
||||
await screen.findByText('backup-2025-01-15.zip');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Refresh' }));
|
||||
|
||||
expect(await screen.findByText('auto-backup-2025-02-01.zip')).toBeInTheDocument();
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
it('FE-MOB-MBKP-007: creating a backup posts, toasts and reloads the list', async () => {
|
||||
const user = userEvent.setup();
|
||||
let created = false;
|
||||
server.use(
|
||||
http.post('/api/backup/create', () => { created = true; return HttpResponse.json({ success: true }); }),
|
||||
http.get('/api/backup/list', () => HttpResponse.json({ backups: created ? [manualBackup, autoBackup] : [manualBackup] })),
|
||||
);
|
||||
withToast();
|
||||
await screen.findByText('backup-2025-01-15.zip');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Create Backup' }));
|
||||
|
||||
expect(await screen.findByText('Backup created successfully')).toBeInTheDocument();
|
||||
expect(await screen.findByText('auto-backup-2025-02-01.zip')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MBKP-008: a failing create shows the create error toast', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(http.post('/api/backup/create', () => HttpResponse.json({ error: 'disk full' }, { status: 500 })));
|
||||
withToast();
|
||||
await screen.findByText('backup-2025-01-15.zip');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Create Backup' }));
|
||||
|
||||
expect(await screen.findByText('Failed to create backup')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MBKP-009: the empty-state shortcut also creates a backup', async () => {
|
||||
const user = userEvent.setup();
|
||||
let created = false;
|
||||
server.use(
|
||||
listHandler([]),
|
||||
http.post('/api/backup/create', () => { created = true; return HttpResponse.json({ success: true }); }),
|
||||
);
|
||||
withToast();
|
||||
await screen.findByText('No backups yet');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Create first backup' }));
|
||||
|
||||
await waitFor(() => expect(created).toBe(true));
|
||||
});
|
||||
|
||||
it('FE-MOB-MBKP-010: a failing download shows the download error toast', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(http.get('/api/backup/download/:filename', () => new HttpResponse(null, { status: 500 })));
|
||||
withToast();
|
||||
await screen.findByText('backup-2025-01-15.zip');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Download' }));
|
||||
|
||||
expect(await screen.findByText('Download failed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MBKP-011: Restore opens the warning sheet and cancelling leaves the API untouched', async () => {
|
||||
const user = userEvent.setup();
|
||||
let restored = false;
|
||||
server.use(http.post('/api/backup/restore/:filename', () => { restored = true; return HttpResponse.json({ ok: true }); }));
|
||||
render(<MAdminBackupPanel />);
|
||||
await screen.findByText('backup-2025-01-15.zip');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Restore' }));
|
||||
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
expect(within(dialog).getByText('Restore Backup?')).toBeInTheDocument();
|
||||
expect(within(dialog).getByText('backup-2025-01-15.zip')).toBeInTheDocument();
|
||||
expect(within(dialog).getByText(/Tip: Create a backup of the current state/)).toBeInTheDocument();
|
||||
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Cancel' }));
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
expect(restored).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-MOB-MBKP-012: confirming a restore calls the API and reloads the page after 1.5s', async () => {
|
||||
const user = userEvent.setup();
|
||||
let restoredFile = '';
|
||||
server.use(http.post('/api/backup/restore/:filename', ({ params }) => {
|
||||
restoredFile = String(params.filename);
|
||||
return HttpResponse.json({ success: true });
|
||||
}));
|
||||
withToast();
|
||||
await screen.findByText('backup-2025-01-15.zip');
|
||||
|
||||
const reload = stubReload();
|
||||
await user.click(screen.getByRole('button', { name: 'Restore' }));
|
||||
await user.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Yes, restore' }));
|
||||
|
||||
expect(await screen.findByText('Backup restored. Page will reload…')).toBeInTheDocument();
|
||||
expect(restoredFile).toBe('backup-2025-01-15.zip');
|
||||
await waitFor(() => expect(reload).toHaveBeenCalled(), { timeout: 4000 });
|
||||
});
|
||||
|
||||
it('FE-MOB-MBKP-013: a failing restore surfaces the server error and clears the busy state', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(http.post('/api/backup/restore/:filename', () =>
|
||||
HttpResponse.json({ error: 'Archive is corrupt' }, { status: 400 }),
|
||||
));
|
||||
withToast();
|
||||
await screen.findByText('backup-2025-01-15.zip');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Restore' }));
|
||||
await user.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Yes, restore' }));
|
||||
|
||||
expect(await screen.findByText('Archive is corrupt')).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Restore' })).toBeEnabled());
|
||||
});
|
||||
|
||||
it('FE-MOB-MBKP-014: picking a file opens the upload confirmation with the file name', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<MAdminBackupPanel />);
|
||||
await screen.findByText('backup-2025-01-15.zip');
|
||||
|
||||
// The visible button only forwards to the hidden picker; jsdom cannot open it
|
||||
const picker = vi.spyOn(fileInput(), 'click');
|
||||
await user.click(screen.getByRole('button', { name: 'Upload Backup' }));
|
||||
expect(picker).toHaveBeenCalled();
|
||||
|
||||
fireEvent.change(fileInput(), {
|
||||
target: { files: [new File(['zip'], 'restore-me.zip', { type: 'application/zip' })] },
|
||||
});
|
||||
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
expect(within(dialog).getByText('restore-me.zip')).toBeInTheDocument();
|
||||
// The picker is cleared so re-selecting the same file fires change again
|
||||
expect(fileInput().value).toBe('');
|
||||
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Cancel' }));
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-MOB-MBKP-015: an empty file selection opens no sheet', async () => {
|
||||
render(<MAdminBackupPanel />);
|
||||
await screen.findByText('backup-2025-01-15.zip');
|
||||
|
||||
fireEvent.change(fileInput(), { target: { files: [] } });
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MBKP-016: confirming an upload restore posts the archive and reloads', async () => {
|
||||
const user = userEvent.setup();
|
||||
let uploaded = false;
|
||||
server.use(http.post('/api/backup/upload-restore', () => { uploaded = true; return HttpResponse.json({ success: true }); }));
|
||||
withToast();
|
||||
await screen.findByText('backup-2025-01-15.zip');
|
||||
|
||||
const reload = stubReload();
|
||||
fireEvent.change(fileInput(), {
|
||||
target: { files: [new File(['zip'], 'restore-me.zip', { type: 'application/zip' })] },
|
||||
});
|
||||
await user.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Yes, restore' }));
|
||||
|
||||
expect(await screen.findByText('Backup restored. Page will reload…')).toBeInTheDocument();
|
||||
expect(uploaded).toBe(true);
|
||||
await waitFor(() => expect(reload).toHaveBeenCalled(), { timeout: 4000 });
|
||||
});
|
||||
|
||||
it('FE-MOB-MBKP-017: a failing upload restore toasts the error and re-enables the upload button', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(http.post('/api/backup/upload-restore', () =>
|
||||
HttpResponse.json({ error: 'Not a TREK archive' }, { status: 400 }),
|
||||
));
|
||||
withToast();
|
||||
await screen.findByText('backup-2025-01-15.zip');
|
||||
|
||||
fireEvent.change(fileInput(), {
|
||||
target: { files: [new File(['zip'], 'broken.zip', { type: 'application/zip' })] },
|
||||
});
|
||||
await user.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Yes, restore' }));
|
||||
|
||||
expect(await screen.findByText('Not a TREK archive')).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Upload Backup' })).toBeEnabled());
|
||||
});
|
||||
|
||||
it('FE-MOB-MBKP-018: deleting asks for confirmation and drops the row on success', async () => {
|
||||
const user = userEvent.setup();
|
||||
let deletedFile = '';
|
||||
server.use(http.delete('/api/backup/:filename', ({ params }) => {
|
||||
deletedFile = String(params.filename);
|
||||
return HttpResponse.json({ success: true });
|
||||
}));
|
||||
withToast();
|
||||
await screen.findByText('backup-2025-01-15.zip');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
expect(within(dialog).getByText('Delete backup "backup-2025-01-15.zip"?')).toBeInTheDocument();
|
||||
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Cancel' }));
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
expect(deletedFile).toBe('');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
await user.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Delete' }));
|
||||
|
||||
expect(await screen.findByText('Backup deleted')).toBeInTheDocument();
|
||||
expect(deletedFile).toBe('backup-2025-01-15.zip');
|
||||
await waitFor(() => expect(screen.getByText('No backups yet')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-MOB-MBKP-019: a failing delete keeps the row and toasts the error', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(http.delete('/api/backup/:filename', () => HttpResponse.json({ error: 'locked' }, { status: 409 })));
|
||||
withToast();
|
||||
await screen.findByText('backup-2025-01-15.zip');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
await user.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Delete' }));
|
||||
|
||||
expect(await screen.findByText('Failed to delete')).toBeInTheDocument();
|
||||
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MBKP-020: the auto-backup schedule is hidden until the toggle is on', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<MAdminBackupPanel />);
|
||||
await screen.findByText('Auto-Backup');
|
||||
|
||||
const toggle = screen.getByRole('switch', { name: 'Enable auto-backup' });
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'false');
|
||||
expect(screen.queryByText('Hourly')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
|
||||
|
||||
await user.click(toggle);
|
||||
|
||||
expect(screen.getByText('Hourly')).toBeInTheDocument();
|
||||
expect(screen.getByText('Daily')).toBeInTheDocument();
|
||||
expect(screen.getByText('Weekly')).toBeInTheDocument();
|
||||
expect(screen.getByText('Monthly')).toBeInTheDocument();
|
||||
expect(screen.getByText('7 days')).toBeInTheDocument();
|
||||
expect(screen.getByText('Keep forever')).toBeInTheDocument();
|
||||
// Changing anything marks the form dirty
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('FE-MOB-MBKP-021: the hour picker renders 24h labels and names the server timezone', async () => {
|
||||
server.use(autoSettingsHandler({ ...DEFAULT_AUTO, enabled: true }, 'Europe/Berlin'));
|
||||
render(<MAdminBackupPanel />);
|
||||
|
||||
expect(await screen.findByText('Run at hour')).toBeInTheDocument();
|
||||
expect(screen.getByText('Server local time (24h format) (Timezone: Europe/Berlin)')).toBeInTheDocument();
|
||||
expect(screen.getByRole('combobox')).toHaveValue('2');
|
||||
expect(screen.getByRole('option', { name: '00:00' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: '23:00' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MBKP-022: with a 12h user preference the hours are labelled AM/PM', async () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ time_format: '12h' }) });
|
||||
server.use(autoSettingsHandler({ ...DEFAULT_AUTO, enabled: true }, null));
|
||||
render(<MAdminBackupPanel />);
|
||||
|
||||
expect(await screen.findByText('Server local time (12h format)')).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: '12:00 AM' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: '11:00 AM' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: '1:00 PM' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MBKP-023: hourly hides the hour picker, weekly shows weekdays, monthly a day select', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(autoSettingsHandler({ ...DEFAULT_AUTO, enabled: true }));
|
||||
render(<MAdminBackupPanel />);
|
||||
await screen.findByText('Run at hour');
|
||||
|
||||
await user.click(screen.getByText('Hourly'));
|
||||
expect(screen.queryByText('Run at hour')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByText('Weekly'));
|
||||
expect(screen.getByText('Day of week')).toBeInTheDocument();
|
||||
expect(screen.getByText('Sun')).toBeInTheDocument();
|
||||
expect(screen.getByText('Sat')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByText('Monthly'));
|
||||
expect(screen.queryByText('Day of week')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Day of month')).toBeInTheDocument();
|
||||
// Hour picker plus the 1–28 day picker
|
||||
const selects = screen.getAllByRole('combobox');
|
||||
expect(selects).toHaveLength(2);
|
||||
expect(within(selects[1]).getAllByRole('option')).toHaveLength(28);
|
||||
|
||||
fireEvent.change(selects[1], { target: { value: '15' } });
|
||||
expect(selects[1]).toHaveValue('15');
|
||||
});
|
||||
|
||||
it('FE-MOB-MBKP-024: saving sends the edited schedule and confirms with a toast', async () => {
|
||||
const user = userEvent.setup();
|
||||
let body: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
autoSettingsHandler({ ...DEFAULT_AUTO, enabled: true }),
|
||||
http.put('/api/backup/auto-settings', async ({ request }) => {
|
||||
body = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ settings: body });
|
||||
}),
|
||||
);
|
||||
withToast();
|
||||
await screen.findByText('Run at hour');
|
||||
|
||||
await user.click(screen.getByText('Weekly'));
|
||||
await user.click(screen.getByText('Fri'));
|
||||
await user.click(screen.getByText('30 days'));
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: '5' } });
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
expect(await screen.findByText('Auto-backup settings saved')).toBeInTheDocument();
|
||||
expect(body).toEqual({ enabled: true, interval: 'weekly', keep_days: 30, hour: 5, day_of_week: 5, day_of_month: 1 });
|
||||
// Server echo resets the dirty flag
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled());
|
||||
});
|
||||
|
||||
it('FE-MOB-MBKP-025: a failing save toasts the settings error and keeps the form dirty', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
autoSettingsHandler({ ...DEFAULT_AUTO, enabled: true }),
|
||||
http.put('/api/backup/auto-settings', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
|
||||
);
|
||||
withToast();
|
||||
await screen.findByText('Run at hour');
|
||||
|
||||
await user.click(screen.getByText('Weekly'));
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
expect(await screen.findByText('Failed to save settings')).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled());
|
||||
});
|
||||
|
||||
it('FE-MOB-MBKP-026: an unreachable auto-settings endpoint leaves the built-in defaults in place', async () => {
|
||||
server.use(http.get('/api/backup/auto-settings', () => HttpResponse.json({}, { status: 500 })));
|
||||
withToast();
|
||||
await screen.findByText('backup-2025-01-15.zip');
|
||||
|
||||
expect(screen.getByRole('switch', { name: 'Enable auto-backup' })).toHaveAttribute('aria-checked', 'false');
|
||||
expect(screen.queryByText('Failed to load backups')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MBKP-027: an unusable server timezone falls back to the raw timestamp', async () => {
|
||||
server.use(autoSettingsHandler(DEFAULT_AUTO, 'Not/AZone'));
|
||||
render(<MAdminBackupPanel />);
|
||||
|
||||
expect(await screen.findByText('2025-01-15T10:00:00Z')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
// FE-MOB-MCAT-001 to FE-MOB-MCAT-016
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, within, fireEvent } from '../../../helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../helpers/msw/server';
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store';
|
||||
import { buildAdmin, buildCategory } from '../../../helpers/factories';
|
||||
import { useAuthStore } from '../../../../src/store/authStore';
|
||||
import { ToastContainer } from '../../../../src/components/shared/Toast';
|
||||
import MAdminCategoryManager from '../../../../src/mobile/screens/admin/MAdminCategoryManager';
|
||||
|
||||
function withToast() {
|
||||
return render(<><ToastContainer /><MAdminCategoryManager /></>);
|
||||
}
|
||||
|
||||
// The preset swatches carry no label — jsdom normalises the inline hex to rgb().
|
||||
function swatch(hex: string): HTMLElement {
|
||||
const rgb = `rgb(${parseInt(hex.slice(1, 3), 16)}, ${parseInt(hex.slice(3, 5), 16)}, ${parseInt(hex.slice(5, 7), 16)})`;
|
||||
const found = Array.from(document.querySelectorAll('button')).find(b => b.style.backgroundColor === rgb);
|
||||
if (!found) throw new Error(`no colour swatch for ${hex}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
describe('MAdminCategoryManager', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
||||
server.use(http.get('/api/categories', () => HttpResponse.json({ categories: [] })));
|
||||
});
|
||||
|
||||
it('FE-MOB-MCAT-001: shows a spinner while the list is loading, then the empty state', async () => {
|
||||
render(<MAdminCategoryManager />);
|
||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
|
||||
expect(await screen.findByText('No categories yet')).toBeInTheDocument();
|
||||
expect(document.querySelector('.animate-spin')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MCAT-002: renders the card head and the New Category action', async () => {
|
||||
render(<MAdminCategoryManager />);
|
||||
await screen.findByText('No categories yet');
|
||||
|
||||
expect(screen.getByText('Categories')).toBeInTheDocument();
|
||||
expect(screen.getByText('Manage categories for places')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'New Category' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MCAT-003: a failing list request surfaces the load error toast', async () => {
|
||||
server.use(http.get('/api/categories', () => HttpResponse.json({ error: 'nope' }, { status: 500 })));
|
||||
withToast();
|
||||
|
||||
expect(await screen.findByText('Failed to load categories')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MCAT-004: lists the categories from the API with name and colour badge', async () => {
|
||||
server.use(http.get('/api/categories', () => HttpResponse.json({
|
||||
categories: [
|
||||
buildCategory({ id: 1, name: 'Museum', color: '#10b981', icon: 'Landmark' }),
|
||||
buildCategory({ id: 2, name: 'Hotel', color: '#3b82f6', icon: 'BedDouble' }),
|
||||
],
|
||||
})));
|
||||
render(<MAdminCategoryManager />);
|
||||
|
||||
expect(await screen.findByText('Museum')).toBeInTheDocument();
|
||||
expect(screen.getByText('Hotel')).toBeInTheDocument();
|
||||
expect(screen.getByText('#10b981')).toBeInTheDocument();
|
||||
expect(screen.getByText('#3b82f6')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MCAT-005: New Category opens the form with the default preview label', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<MAdminCategoryManager />);
|
||||
await screen.findByText('No categories yet');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'New Category' }));
|
||||
|
||||
expect(screen.getByPlaceholderText('Category name')).toHaveValue('');
|
||||
expect(screen.getByText('Icon')).toBeInTheDocument();
|
||||
expect(screen.getByText('Color')).toBeInTheDocument();
|
||||
// Empty name falls back to the generic preview label
|
||||
expect(screen.getByText('Category')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-MOB-MCAT-006: typing a name enables Create and updates the live preview', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<MAdminCategoryManager />);
|
||||
await screen.findByText('No categories yet');
|
||||
await user.click(screen.getByRole('button', { name: 'New Category' }));
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Category name'), 'Parks');
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Create' })).toBeEnabled();
|
||||
expect(screen.getByText('Parks')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MCAT-007: creating posts name, chosen icon and preset colour, and appends the row', async () => {
|
||||
const user = userEvent.setup();
|
||||
let body: Record<string, unknown> | null = null;
|
||||
server.use(http.post('/api/categories', async ({ request }) => {
|
||||
body = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ category: buildCategory({ id: 77, name: 'Parks', color: '#10b981', icon: 'TreePine' }) });
|
||||
}));
|
||||
withToast();
|
||||
await screen.findByText('No categories yet');
|
||||
await user.click(screen.getByRole('button', { name: 'New Category' }));
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Category name'), 'Parks');
|
||||
await user.click(screen.getByTitle('Nature'));
|
||||
await user.click(swatch('#10b981'));
|
||||
await user.click(screen.getByRole('button', { name: 'Create' }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Category created')).toBeInTheDocument());
|
||||
expect(body).toEqual({ name: 'Parks', color: '#10b981', icon: 'TreePine' });
|
||||
// Form closed, new row present with its colour badge
|
||||
expect(screen.queryByPlaceholderText('Category name')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('#10b981')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MCAT-008: a rejected create shows the server error message', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(http.post('/api/categories', () => HttpResponse.json({ error: 'Name already taken' }, { status: 409 })));
|
||||
withToast();
|
||||
await screen.findByText('No categories yet');
|
||||
await user.click(screen.getByRole('button', { name: 'New Category' }));
|
||||
await user.type(screen.getByPlaceholderText('Category name'), 'Parks');
|
||||
await user.click(screen.getByRole('button', { name: 'Create' }));
|
||||
|
||||
expect(await screen.findByText('Name already taken')).toBeInTheDocument();
|
||||
// Form stays open so the admin can correct the name
|
||||
expect(screen.getByPlaceholderText('Category name')).toHaveValue('Parks');
|
||||
});
|
||||
|
||||
it('FE-MOB-MCAT-009: Cancel closes the create form', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<MAdminCategoryManager />);
|
||||
await screen.findByText('No categories yet');
|
||||
await user.click(screen.getByRole('button', { name: 'New Category' }));
|
||||
expect(screen.getByPlaceholderText('Category name')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
|
||||
expect(screen.queryByPlaceholderText('Category name')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MCAT-010: the custom colour input marks the colour as non-preset and is sent on save', async () => {
|
||||
const user = userEvent.setup();
|
||||
let body: Record<string, unknown> | null = null;
|
||||
server.use(http.post('/api/categories', async ({ request }) => {
|
||||
body = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ category: buildCategory({ id: 3, name: 'Custom', color: '#123456' }) });
|
||||
}));
|
||||
withToast();
|
||||
await screen.findByText('No categories yet');
|
||||
await user.click(screen.getByRole('button', { name: 'New Category' }));
|
||||
|
||||
// The eyedropper button only forwards to the hidden colour input; drive it directly.
|
||||
const pickerButton = screen.getByTitle('Choose custom color');
|
||||
await user.click(pickerButton);
|
||||
const colorInput = document.querySelector('input[type="color"]') as HTMLInputElement;
|
||||
fireEvent.change(colorInput, { target: { value: '#123456' } });
|
||||
|
||||
expect(pickerButton.style.backgroundColor).toBe('rgb(18, 52, 86)');
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Category name'), 'Custom');
|
||||
await user.click(screen.getByRole('button', { name: 'Create' }));
|
||||
|
||||
await waitFor(() => expect(body).toEqual({ name: 'Custom', color: '#123456', icon: 'MapPin' }));
|
||||
});
|
||||
|
||||
it('FE-MOB-MCAT-011: the edit button opens an inline form prefilled from the category', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(http.get('/api/categories', () => HttpResponse.json({
|
||||
categories: [buildCategory({ id: 5, name: 'Hotels', color: '#ef4444', icon: 'BedDouble' })],
|
||||
})));
|
||||
render(<MAdminCategoryManager />);
|
||||
await screen.findByText('Hotels');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Edit' }));
|
||||
|
||||
expect(screen.getByDisplayValue('Hotels')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Update' })).toBeInTheDocument();
|
||||
expect(document.querySelector('input[type="color"]')).toHaveValue('#ef4444');
|
||||
});
|
||||
|
||||
it('FE-MOB-MCAT-012: saving an edit PUTs the change and replaces the row in place', async () => {
|
||||
const user = userEvent.setup();
|
||||
let putUrl = '';
|
||||
server.use(
|
||||
http.get('/api/categories', () => HttpResponse.json({
|
||||
categories: [buildCategory({ id: 5, name: 'Hotels', color: '#ef4444', icon: 'BedDouble' })],
|
||||
})),
|
||||
http.put('/api/categories/:id', async ({ request, params }) => {
|
||||
putUrl = String(params.id);
|
||||
const b = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ category: buildCategory({ id: 5, name: String(b.name), color: '#ef4444' }) });
|
||||
}),
|
||||
);
|
||||
withToast();
|
||||
await screen.findByText('Hotels');
|
||||
await user.click(screen.getByRole('button', { name: 'Edit' }));
|
||||
|
||||
const input = screen.getByDisplayValue('Hotels');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'Lodging');
|
||||
await user.click(screen.getByRole('button', { name: 'Update' }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Category updated')).toBeInTheDocument());
|
||||
expect(putUrl).toBe('5');
|
||||
expect(screen.getByText('Lodging')).toBeInTheDocument();
|
||||
expect(screen.queryByDisplayValue('Lodging')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MCAT-013: opening the edit form closes an open create form', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(http.get('/api/categories', () => HttpResponse.json({
|
||||
categories: [buildCategory({ id: 5, name: 'Hotels' })],
|
||||
})));
|
||||
render(<MAdminCategoryManager />);
|
||||
await screen.findByText('Hotels');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'New Category' }));
|
||||
expect(screen.getAllByPlaceholderText('Category name')).toHaveLength(1);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Edit' }));
|
||||
|
||||
// Only the inline edit form remains, prefilled
|
||||
const inputs = screen.getAllByPlaceholderText('Category name');
|
||||
expect(inputs).toHaveLength(1);
|
||||
expect(inputs[0]).toHaveValue('Hotels');
|
||||
});
|
||||
|
||||
it('FE-MOB-MCAT-014: the trash button asks for confirmation before deleting', async () => {
|
||||
const user = userEvent.setup();
|
||||
let deleted = false;
|
||||
server.use(
|
||||
http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 9, name: 'Parks' })] })),
|
||||
http.delete('/api/categories/9', () => { deleted = true; return HttpResponse.json({ success: true }); }),
|
||||
);
|
||||
withToast();
|
||||
await screen.findByText('Parks');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
expect(within(dialog).getByText('Delete category? Places in this category will not be deleted.')).toBeInTheDocument();
|
||||
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Cancel' }));
|
||||
// The sheet stays mounted through its exit animation
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
expect(deleted).toBe(false);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
await user.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Delete' }));
|
||||
|
||||
await waitFor(() => expect(deleted).toBe(true));
|
||||
expect(await screen.findByText('Category deleted')).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.queryByText('Parks')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-MOB-MCAT-015: a failing delete keeps the row and shows the server error', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 9, name: 'Parks' })] })),
|
||||
http.delete('/api/categories/9', () => HttpResponse.json({ error: 'Category in use' }, { status: 400 })),
|
||||
);
|
||||
withToast();
|
||||
await screen.findByText('Parks');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
await user.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Delete' }));
|
||||
|
||||
expect(await screen.findByText('Category in use')).toBeInTheDocument();
|
||||
expect(screen.getByText('Parks')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MCAT-016: picking an icon in the edit form is sent with the update', async () => {
|
||||
const user = userEvent.setup();
|
||||
let body: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.get('/api/categories', () => HttpResponse.json({
|
||||
categories: [buildCategory({ id: 5, name: 'Hotels', color: '#ef4444', icon: 'BedDouble' })],
|
||||
})),
|
||||
http.put('/api/categories/5', async ({ request }) => {
|
||||
body = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ category: buildCategory({ id: 5, name: 'Hotels' }) });
|
||||
}),
|
||||
);
|
||||
render(<MAdminCategoryManager />);
|
||||
await screen.findByText('Hotels');
|
||||
await user.click(screen.getByRole('button', { name: 'Edit' }));
|
||||
|
||||
await user.click(screen.getByTitle('Camping'));
|
||||
await user.click(screen.getByRole('button', { name: 'Update' }));
|
||||
|
||||
await waitFor(() => expect(body).toEqual({ name: 'Hotels', color: '#ef4444', icon: 'Tent' }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,379 @@
|
||||
// FE-MOB-MDUS-001 to FE-MOB-MDUS-024
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, waitFor, within, fireEvent } from '../../../helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../helpers/msw/server';
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store';
|
||||
import { buildAdmin } from '../../../helpers/factories';
|
||||
import { useAuthStore } from '../../../../src/store/authStore';
|
||||
import { ToastContainer } from '../../../../src/components/shared/Toast';
|
||||
import MAdminDefaultUserSettings from '../../../../src/mobile/screens/admin/MAdminDefaultUserSettings';
|
||||
|
||||
// The live tile preview would pull Leaflet into jsdom; the panel only needs it to render.
|
||||
vi.mock('../../../../src/components/Map/MapView', () => ({
|
||||
MapView: ({ tileUrl }: { tileUrl?: string }) => <div data-testid="map-preview" data-tile={tileUrl} />,
|
||||
}));
|
||||
|
||||
const MAPBOX_STANDARD = 'mapbox://styles/mapbox/standard';
|
||||
const OFM_LIBERTY = 'https://tiles.openfreemap.org/styles/liberty';
|
||||
|
||||
/** Stateful stand-in for the admin defaults endpoint: PUT merges, null deletes. */
|
||||
function stubDefaults(initial: Record<string, unknown> = {}) {
|
||||
const state: Record<string, unknown> = { ...initial };
|
||||
const puts: Record<string, unknown>[] = [];
|
||||
server.use(
|
||||
http.get('/api/admin/default-user-settings', () => HttpResponse.json(state)),
|
||||
http.put('/api/admin/default-user-settings', async ({ request }) => {
|
||||
const body = await request.json() as Record<string, unknown>;
|
||||
puts.push(body);
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
if (value === null) delete state[key];
|
||||
else state[key] = value;
|
||||
}
|
||||
return HttpResponse.json({ ...state });
|
||||
}),
|
||||
);
|
||||
return { puts, state };
|
||||
}
|
||||
|
||||
function withToast() {
|
||||
return render(<><ToastContainer /><MAdminDefaultUserSettings /></>);
|
||||
}
|
||||
|
||||
/** The reset link lives inside the field label next to its caption. */
|
||||
function resetLink(label: string): HTMLElement {
|
||||
const holder = screen.getAllByText(label).find(el => el.querySelector('button'));
|
||||
if (!holder) throw new Error(`no reset link next to "${label}"`);
|
||||
return within(holder).getByRole('button', { name: 'reset' });
|
||||
}
|
||||
|
||||
describe('MAdminDefaultUserSettings', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
||||
stubDefaults();
|
||||
});
|
||||
|
||||
it('FE-MOB-MDUS-001: shows the loading placeholder until the defaults arrive', async () => {
|
||||
render(<MAdminDefaultUserSettings />);
|
||||
expect(screen.getByText('Loading…')).toBeInTheDocument();
|
||||
|
||||
expect(await screen.findByText('Default User Settings')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MDUS-002: renders every default field with no preselected segment when nothing is set', async () => {
|
||||
render(<MAdminDefaultUserSettings />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
expect(screen.getByText(/Set instance-wide defaults/)).toBeInTheDocument();
|
||||
for (const name of ['Light', 'Dark', 'Auto', '°C Celsius', 'km Metric', '24h (14:30)']) {
|
||||
expect(screen.getByRole('tab', { name })).toHaveAttribute('aria-selected', 'false');
|
||||
}
|
||||
// No default chosen yet → no reset links anywhere
|
||||
expect(screen.queryByRole('button', { name: 'reset' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Display currency' })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('map-preview')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MDUS-003: a failing load still renders the (empty) panel', async () => {
|
||||
server.use(http.get('/api/admin/default-user-settings', () => HttpResponse.json({}, { status: 500 })));
|
||||
render(<MAdminDefaultUserSettings />);
|
||||
|
||||
expect(await screen.findByText('Default User Settings')).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'Standard (free)' })).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
it('FE-MOB-MDUS-004: picking a colour mode saves it and shows the confirmation toast', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults();
|
||||
withToast();
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Dark' }));
|
||||
|
||||
expect(await screen.findByText('Default saved')).toBeInTheDocument();
|
||||
expect(puts).toEqual([{ dark_mode: 'dark' }]);
|
||||
await waitFor(() => expect(screen.getByRole('tab', { name: 'Dark' })).toHaveAttribute('aria-selected', 'true'));
|
||||
});
|
||||
|
||||
it('FE-MOB-MDUS-005: a legacy boolean dark_mode still highlights the matching segment', async () => {
|
||||
stubDefaults({ dark_mode: true });
|
||||
render(<MAdminDefaultUserSettings />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
expect(screen.getByRole('tab', { name: 'Dark' })).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
it('FE-MOB-MDUS-006: dark_mode false maps to Light, "auto" maps to Auto', async () => {
|
||||
stubDefaults({ dark_mode: false });
|
||||
const { unmount } = render(<MAdminDefaultUserSettings />);
|
||||
await screen.findByText('Default User Settings');
|
||||
expect(screen.getByRole('tab', { name: 'Light' })).toHaveAttribute('aria-selected', 'true');
|
||||
unmount();
|
||||
|
||||
stubDefaults({ dark_mode: 'auto' });
|
||||
render(<MAdminDefaultUserSettings />);
|
||||
await screen.findByText('Default User Settings');
|
||||
expect(screen.getByRole('tab', { name: 'Auto' })).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
it('FE-MOB-MDUS-007: unit and time-format segments each save their own key', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults();
|
||||
render(<MAdminDefaultUserSettings />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: '°F Fahrenheit' }));
|
||||
await waitFor(() => expect(screen.getByRole('tab', { name: '°F Fahrenheit' })).toHaveAttribute('aria-selected', 'true'));
|
||||
await user.click(screen.getByRole('tab', { name: 'mi Imperial' }));
|
||||
await waitFor(() => expect(screen.getByRole('tab', { name: 'mi Imperial' })).toHaveAttribute('aria-selected', 'true'));
|
||||
await user.click(screen.getByRole('tab', { name: '12h (2:30 PM)' }));
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([
|
||||
{ temperature_unit: 'fahrenheit' },
|
||||
{ distance_unit: 'imperial' },
|
||||
{ time_format: '12h' },
|
||||
]));
|
||||
});
|
||||
|
||||
it('FE-MOB-MDUS-008: a set default gets a reset link that clears it server-side', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts, state } = stubDefaults({ temperature_unit: 'celsius' });
|
||||
withToast();
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
expect(screen.getByRole('tab', { name: '°C Celsius' })).toHaveAttribute('aria-selected', 'true');
|
||||
await user.click(resetLink('Temperature Unit'));
|
||||
|
||||
expect(await screen.findByText('Reset to built-in default')).toBeInTheDocument();
|
||||
expect(puts).toEqual([{ temperature_unit: null }]);
|
||||
expect(state.temperature_unit).toBeUndefined();
|
||||
await waitFor(() => expect(screen.getByRole('tab', { name: '°C Celsius' })).toHaveAttribute('aria-selected', 'false'));
|
||||
});
|
||||
|
||||
it('FE-MOB-MDUS-009: the currency row shows the symbol and the picker saves the chosen code', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults({ default_currency: 'USD' });
|
||||
render(<MAdminDefaultUserSettings />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'USD $' }));
|
||||
const sheet = await screen.findByRole('dialog');
|
||||
await user.click(within(sheet).getByRole('button', { name: 'EUR €' }));
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([{ default_currency: 'EUR' }]));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'EUR €' })).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-MOB-MDUS-010: the blur-booking-codes toggle saves a boolean default', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults();
|
||||
render(<MAdminDefaultUserSettings />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
const toggle = screen.getByRole('switch', { name: 'Blur Booking Codes' });
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'false');
|
||||
await user.click(toggle);
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([{ blur_booking_codes: true }]));
|
||||
await waitFor(() => expect(screen.getByRole('switch', { name: 'Blur Booking Codes' })).toHaveAttribute('aria-checked', 'true'));
|
||||
});
|
||||
|
||||
it('FE-MOB-MDUS-011: the tile URL is saved on blur and handed to the preview map', async () => {
|
||||
const { puts } = stubDefaults();
|
||||
render(<MAdminDefaultUserSettings />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
// Typed through fireEvent: the {z}/{x}/{y} placeholders collide with userEvent's key syntax
|
||||
const input = screen.getByPlaceholderText('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png');
|
||||
fireEvent.change(input, { target: { value: 'https://tiles.example.org/{z}/{x}/{y}.png' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([{ map_tile_url: 'https://tiles.example.org/{z}/{x}/{y}.png' }]));
|
||||
expect(screen.getByTestId('map-preview')).toHaveAttribute('data-tile', 'https://tiles.example.org/{z}/{x}/{y}.png');
|
||||
});
|
||||
|
||||
it('FE-MOB-MDUS-012: the tile preset sheet fills the URL field and saves it', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults();
|
||||
render(<MAdminDefaultUserSettings />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Select template...' }));
|
||||
const sheet = await screen.findByRole('dialog');
|
||||
await user.click(within(sheet).getByRole('button', { name: 'CartoDB Dark' }));
|
||||
|
||||
const url = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
|
||||
await waitFor(() => expect(puts).toEqual([{ map_tile_url: url }]));
|
||||
expect(screen.getByPlaceholderText('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png')).toHaveValue(url);
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'CartoDB Dark' })).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-MOB-MDUS-013: resetting the tile URL clears the input as well', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults({ map_tile_url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' });
|
||||
render(<MAdminDefaultUserSettings />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
expect(screen.getByRole('button', { name: 'OpenStreetMap DE' })).toBeInTheDocument();
|
||||
await user.click(resetLink('Map Template'));
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([{ map_tile_url: null }]));
|
||||
await waitFor(() => expect(screen.getByPlaceholderText('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png')).toHaveValue(''));
|
||||
});
|
||||
|
||||
it('FE-MOB-MDUS-014: leaflet hides the GL-only token and style fields', async () => {
|
||||
render(<MAdminDefaultUserSettings />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
expect(screen.getByRole('tab', { name: 'Standard (free)' })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.queryByText('Map style')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Shared Mapbox token')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MDUS-015: switching to Mapbox stores the provider together with its own style slot', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults();
|
||||
render(<MAdminDefaultUserSettings />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Mapbox (3D)' }));
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([{ map_provider: 'mapbox-gl', mapbox_style: MAPBOX_STANDARD }]));
|
||||
expect(await screen.findByText('Shared Mapbox token')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Mapbox Standard' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('switch', { name: '3D buildings & terrain' })).toHaveAttribute('aria-checked', 'true');
|
||||
expect(screen.getByRole('switch', { name: 'High-quality mode' })).toHaveAttribute('aria-checked', 'false');
|
||||
});
|
||||
|
||||
it('FE-MOB-MDUS-016: switching to MapLibre stores the OpenFreeMap default in maplibre_style', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults();
|
||||
render(<MAdminDefaultUserSettings />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'MapLibre (OpenFreeMap)' }));
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([{ map_provider: 'maplibre-gl', maplibre_style: OFM_LIBERTY }]));
|
||||
// MapLibre needs no shared token
|
||||
expect(screen.queryByText('Shared Mapbox token')).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole('button', { name: 'OpenFreeMap Liberty' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MDUS-017: a Mapbox default holding an OpenFreeMap style falls back to the Mapbox standard', async () => {
|
||||
stubDefaults({ map_provider: 'mapbox-gl', mapbox_style: OFM_LIBERTY });
|
||||
render(<MAdminDefaultUserSettings />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Mapbox Standard' })).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue(MAPBOX_STANDARD)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MDUS-018: the shared Mapbox token is stored on blur and cleared by its reset link', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults({ map_provider: 'mapbox-gl', mapbox_access_token: 'pk.old' });
|
||||
render(<MAdminDefaultUserSettings />);
|
||||
await screen.findByText('Shared Mapbox token');
|
||||
|
||||
const input = screen.getByPlaceholderText('pk.eyJ…');
|
||||
expect(input).toHaveValue('pk.old');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'pk.new');
|
||||
input.blur();
|
||||
await waitFor(() => expect(puts).toEqual([{ mapbox_access_token: 'pk.new' }]));
|
||||
|
||||
await user.click(resetLink('Shared Mapbox token'));
|
||||
await waitFor(() => expect(puts).toHaveLength(2));
|
||||
expect(puts[1]).toEqual({ mapbox_access_token: null });
|
||||
await waitFor(() => expect(screen.getByPlaceholderText('pk.eyJ…')).toHaveValue(''));
|
||||
});
|
||||
|
||||
it('FE-MOB-MDUS-019: a hand-typed MapLibre style is normalised to OpenFreeMap on blur', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults({ map_provider: 'maplibre-gl', maplibre_style: OFM_LIBERTY });
|
||||
render(<MAdminDefaultUserSettings />);
|
||||
await screen.findByText('Map style');
|
||||
|
||||
const input = screen.getByDisplayValue(OFM_LIBERTY);
|
||||
await user.clear(input);
|
||||
await user.type(input, 'https://example.com/custom.json');
|
||||
input.blur();
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([{ maplibre_style: OFM_LIBERTY }]));
|
||||
expect(input).toHaveValue(OFM_LIBERTY);
|
||||
});
|
||||
|
||||
it('FE-MOB-MDUS-020: the style sheet writes the picked preset into the active provider slot', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults({ map_provider: 'mapbox-gl', mapbox_style: MAPBOX_STANDARD });
|
||||
render(<MAdminDefaultUserSettings />);
|
||||
await screen.findByText('Map style');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Mapbox Standard' }));
|
||||
const sheet = await screen.findByRole('dialog');
|
||||
await user.click(within(sheet).getByRole('button', { name: 'Navigation Night' }));
|
||||
|
||||
const url = 'mapbox://styles/mapbox/navigation-night-v1';
|
||||
await waitFor(() => expect(puts).toEqual([{ mapbox_style: url }]));
|
||||
expect(screen.getByDisplayValue(url)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MDUS-021: resetting the style restores the provider default in the field', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults({ map_provider: 'mapbox-gl', mapbox_style: 'mapbox://styles/mapbox/dark-v11' });
|
||||
render(<MAdminDefaultUserSettings />);
|
||||
await screen.findByText('Map style');
|
||||
|
||||
await user.click(resetLink('Map style'));
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([{ mapbox_style: null }]));
|
||||
await waitFor(() => expect(screen.getByDisplayValue(MAPBOX_STANDARD)).toBeInTheDocument());
|
||||
expect(screen.getByRole('button', { name: 'Mapbox Standard' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MDUS-022: the Mapbox 3D and quality toggles save their own keys', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults({ map_provider: 'mapbox-gl', mapbox_3d_enabled: true, mapbox_quality_mode: false });
|
||||
render(<MAdminDefaultUserSettings />);
|
||||
await screen.findByText('Map style');
|
||||
|
||||
await user.click(screen.getByRole('switch', { name: '3D buildings & terrain' }));
|
||||
await waitFor(() => expect(screen.getByRole('switch', { name: '3D buildings & terrain' })).toHaveAttribute('aria-checked', 'false'));
|
||||
await user.click(screen.getByRole('switch', { name: 'High-quality mode' }));
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([{ mapbox_3d_enabled: false }, { mapbox_quality_mode: true }]));
|
||||
});
|
||||
|
||||
it('FE-MOB-MDUS-023: a rejected save surfaces the request error instead of a success toast', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/default-user-settings', () => HttpResponse.json({})),
|
||||
http.put('/api/admin/default-user-settings', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
|
||||
);
|
||||
withToast();
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Dark' }));
|
||||
|
||||
expect(await screen.findByText(/Request failed with status code 500/)).toBeInTheDocument();
|
||||
expect(screen.queryByText('Default saved')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MDUS-024: a rejected reset keeps the value and reports the failure', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/default-user-settings', () => HttpResponse.json({ temperature_unit: 'celsius' })),
|
||||
http.put('/api/admin/default-user-settings', () => HttpResponse.json({ error: 'nope' }, { status: 503 })),
|
||||
);
|
||||
withToast();
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
await user.click(resetLink('Temperature Unit'));
|
||||
|
||||
expect(await screen.findByText(/Request failed with status code 503/)).toBeInTheDocument();
|
||||
expect(screen.queryByText('Reset to built-in default')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: '°C Celsius' })).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,337 @@
|
||||
// FE-MOB-ADEV-001 to FE-MOB-ADEV-015
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render, screen, waitFor } from '../../../helpers/render';
|
||||
import { server } from '../../../helpers/msw/server';
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store';
|
||||
import { buildAdmin } from '../../../helpers/factories';
|
||||
import { useAuthStore } from '../../../../src/store/authStore';
|
||||
import { ToastContainer } from '../../../../src/components/shared/Toast';
|
||||
import MAdminDevNotificationsPanel from '../../../../src/mobile/screens/admin/MAdminDevNotificationsPanel';
|
||||
|
||||
const ADMIN = buildAdmin({ id: 7, username: 'testadmin', email: 'admin@example.com' });
|
||||
|
||||
function renderPanel() {
|
||||
return render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<MAdminDevNotificationsPanel />
|
||||
</>,
|
||||
);
|
||||
}
|
||||
|
||||
/** Captures every POST body the panel sends while a test runs. */
|
||||
function captureSends() {
|
||||
const bodies: Record<string, unknown>[] = [];
|
||||
server.use(
|
||||
http.post('/api/admin/dev/test-notification', async ({ request }) => {
|
||||
bodies.push((await request.json()) as Record<string, unknown>);
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
return bodies;
|
||||
}
|
||||
|
||||
function clickButton(user: ReturnType<typeof userEvent.setup>, label: string) {
|
||||
return user.click(screen.getByText(label).closest('button')!);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: ADMIN });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('MAdminDevNotificationsPanel', () => {
|
||||
it('FE-MOB-ADEV-001: renders the dev badge and all four sections once the data is in', async () => {
|
||||
renderPanel();
|
||||
|
||||
expect(screen.getByText('DEV ONLY')).toBeInTheDocument();
|
||||
expect(screen.getByText('Notification Testing')).toBeInTheDocument();
|
||||
expect(screen.getByText('Type Testing')).toBeInTheDocument();
|
||||
expect(screen.getByText('Admin-Scoped Events')).toBeInTheDocument();
|
||||
await screen.findByText('Trip-Scoped Events');
|
||||
await screen.findByText('User-Scoped Events');
|
||||
});
|
||||
|
||||
it('FE-MOB-ADEV-002: the trip selector lists the trips from the API and preselects the first', async () => {
|
||||
renderPanel();
|
||||
await screen.findByText('Trip-Scoped Events');
|
||||
|
||||
const [tripSelect] = screen.getAllByRole('combobox');
|
||||
const labels = Array.from(tripSelect.querySelectorAll('option')).map((o) => o.textContent);
|
||||
expect(labels).toContain('Paris Adventure');
|
||||
expect(labels).toContain('Tokyo Trip');
|
||||
expect((tripSelect as HTMLSelectElement).value).toBe(
|
||||
(tripSelect.querySelector('option') as HTMLOptionElement).value,
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-MOB-ADEV-003: the user selector shows username and email of every user', async () => {
|
||||
renderPanel();
|
||||
await screen.findByText('User-Scoped Events');
|
||||
|
||||
const userSelect = screen.getAllByRole('combobox')[1];
|
||||
const labels = Array.from(userSelect.querySelectorAll('option')).map((o) => o.textContent ?? '');
|
||||
expect(labels).toContain('admin (admin@example.com)');
|
||||
expect(labels).toContain('alice (alice@example.com)');
|
||||
});
|
||||
|
||||
it('FE-MOB-ADEV-004: the type-testing buttons send the documented payloads', async () => {
|
||||
const bodies = captureSends();
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
await screen.findByText('Type Testing');
|
||||
|
||||
await clickButton(user, 'Simple → Me');
|
||||
await waitFor(() => expect(bodies).toHaveLength(1));
|
||||
expect(bodies[0]).toEqual({ event: 'test_simple', scope: 'user', targetId: 7, params: {} });
|
||||
|
||||
await clickButton(user, 'Boolean → Me');
|
||||
await waitFor(() => expect(bodies).toHaveLength(2));
|
||||
expect(bodies[1]).toMatchObject({
|
||||
event: 'test_boolean',
|
||||
scope: 'user',
|
||||
targetId: 7,
|
||||
inApp: {
|
||||
type: 'boolean',
|
||||
positiveCallback: { action: 'test_approve', payload: {} },
|
||||
negativeCallback: { action: 'test_deny', payload: {} },
|
||||
},
|
||||
});
|
||||
|
||||
await clickButton(user, 'Navigate → Me');
|
||||
await waitFor(() => expect(bodies).toHaveLength(3));
|
||||
expect(bodies[2]).toMatchObject({ event: 'test_navigate', scope: 'user', targetId: 7 });
|
||||
|
||||
await clickButton(user, 'Simple → All Admins');
|
||||
await waitFor(() => expect(bodies).toHaveLength(4));
|
||||
expect(bodies[3]).toMatchObject({ event: 'test_simple', scope: 'admin', targetId: 0 });
|
||||
});
|
||||
|
||||
it('FE-MOB-ADEV-005: every trip-scoped button targets the selected trip with its own params', async () => {
|
||||
const bodies = captureSends();
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
await screen.findByText('Trip-Scoped Events');
|
||||
|
||||
const [tripSelect] = screen.getAllByRole('combobox');
|
||||
const tripId = Number((tripSelect as HTMLSelectElement).value);
|
||||
const tripTitle = tripSelect.querySelector('option')!.textContent;
|
||||
|
||||
await clickButton(user, 'booking_change');
|
||||
await waitFor(() => expect(bodies).toHaveLength(1));
|
||||
expect(bodies[0]).toEqual({
|
||||
event: 'booking_change',
|
||||
scope: 'trip',
|
||||
targetId: tripId,
|
||||
params: {
|
||||
actor: 'testadmin',
|
||||
trip: tripTitle,
|
||||
booking: 'Test Hotel',
|
||||
type: 'hotel',
|
||||
tripId: String(tripId),
|
||||
},
|
||||
});
|
||||
|
||||
await clickButton(user, 'trip_reminder');
|
||||
await waitFor(() => expect(bodies).toHaveLength(2));
|
||||
expect(bodies[1]).toEqual({
|
||||
event: 'trip_reminder',
|
||||
scope: 'trip',
|
||||
targetId: tripId,
|
||||
params: { trip: tripTitle, tripId: String(tripId) },
|
||||
});
|
||||
|
||||
await clickButton(user, 'photos_shared');
|
||||
await waitFor(() => expect(bodies).toHaveLength(3));
|
||||
expect(bodies[2]).toMatchObject({ event: 'photos_shared', params: { count: '5' } });
|
||||
|
||||
await clickButton(user, 'collab_message');
|
||||
await waitFor(() => expect(bodies).toHaveLength(4));
|
||||
expect(bodies[3]).toMatchObject({
|
||||
event: 'collab_message',
|
||||
params: { preview: 'This is a test message preview.' },
|
||||
});
|
||||
|
||||
await clickButton(user, 'packing_tagged');
|
||||
await waitFor(() => expect(bodies).toHaveLength(5));
|
||||
expect(bodies[4]).toMatchObject({ event: 'packing_tagged', params: { category: 'Clothing' } });
|
||||
});
|
||||
|
||||
it('FE-MOB-ADEV-006: the user-scoped buttons target the selected recipient', async () => {
|
||||
const bodies = captureSends();
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
await screen.findByText('User-Scoped Events');
|
||||
|
||||
const userSelect = screen.getAllByRole('combobox')[1] as HTMLSelectElement;
|
||||
const recipientId = Number(userSelect.value);
|
||||
|
||||
await clickButton(user, 'trip_invite');
|
||||
await waitFor(() => expect(bodies).toHaveLength(1));
|
||||
expect(bodies[0]).toMatchObject({
|
||||
event: 'trip_invite',
|
||||
scope: 'user',
|
||||
targetId: recipientId,
|
||||
params: { actor: 'testadmin', invitee: 'admin@example.com' },
|
||||
});
|
||||
|
||||
await clickButton(user, 'vacay_invite');
|
||||
await waitFor(() => expect(bodies).toHaveLength(2));
|
||||
expect(bodies[1]).toEqual({
|
||||
event: 'vacay_invite',
|
||||
scope: 'user',
|
||||
targetId: recipientId,
|
||||
params: { actor: 'testadmin', planId: '1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-MOB-ADEV-007: the admin-scoped button announces a fake version', async () => {
|
||||
const bodies = captureSends();
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
await screen.findByText('Admin-Scoped Events');
|
||||
|
||||
await clickButton(user, 'version_available');
|
||||
await waitFor(() => expect(bodies).toHaveLength(1));
|
||||
expect(bodies[0]).toEqual({
|
||||
event: 'version_available',
|
||||
scope: 'admin',
|
||||
targetId: 0,
|
||||
params: { version: '9.9.9-test' },
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-MOB-ADEV-008: changing the trip selector changes the payload target', async () => {
|
||||
const bodies = captureSends();
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
await screen.findByText('Trip-Scoped Events');
|
||||
|
||||
const [tripSelect] = screen.getAllByRole('combobox');
|
||||
const tokyo = Array.from(tripSelect.querySelectorAll('option')).find(
|
||||
(o) => o.textContent === 'Tokyo Trip',
|
||||
)!;
|
||||
await user.selectOptions(tripSelect, tokyo.value);
|
||||
await clickButton(user, 'trip_reminder');
|
||||
|
||||
await waitFor(() => expect(bodies).toHaveLength(1));
|
||||
expect(bodies[0]).toMatchObject({ targetId: Number(tokyo.value), params: { trip: 'Tokyo Trip' } });
|
||||
});
|
||||
|
||||
it('FE-MOB-ADEV-009: changing the user selector changes the recipient', async () => {
|
||||
const bodies = captureSends();
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
await screen.findByText('User-Scoped Events');
|
||||
|
||||
const userSelect = screen.getAllByRole('combobox')[1];
|
||||
const alice = Array.from(userSelect.querySelectorAll('option')).find(
|
||||
(o) => o.textContent?.startsWith('alice'),
|
||||
)!;
|
||||
await user.selectOptions(userSelect, alice.value);
|
||||
await clickButton(user, 'trip_invite');
|
||||
|
||||
await waitFor(() => expect(bodies).toHaveLength(1));
|
||||
expect(bodies[0]).toMatchObject({
|
||||
targetId: Number(alice.value),
|
||||
params: { invitee: 'alice@example.com' },
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-MOB-ADEV-010: a successful send raises a toast naming the fired button', async () => {
|
||||
captureSends();
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
await screen.findByText('Type Testing');
|
||||
|
||||
await clickButton(user, 'Simple → Me');
|
||||
expect(await screen.findByText('Sent: simple-me')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ADEV-011: a failing send raises an error toast', async () => {
|
||||
server.use(
|
||||
http.post('/api/admin/dev/test-notification', () => HttpResponse.json({}, { status: 500 })),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
await screen.findByText('Type Testing');
|
||||
|
||||
await clickButton(user, 'Simple → Me');
|
||||
expect(await screen.findByText(/failed|error/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ADEV-012: every button is disabled while a send is in flight', async () => {
|
||||
server.use(
|
||||
http.post('/api/admin/dev/test-notification', async () => {
|
||||
await new Promise(() => {});
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
await screen.findByText('Type Testing');
|
||||
|
||||
void clickButton(user, 'Simple → Me');
|
||||
|
||||
await waitFor(() => {
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons.length).toBeGreaterThan(1);
|
||||
buttons.forEach((btn) => expect(btn).toBeDisabled());
|
||||
});
|
||||
// The fired button shows its own spinner
|
||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ADEV-013: the scoped sections stay hidden when both lookups fail', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips', () => HttpResponse.json({}, { status: 500 })),
|
||||
http.get('/api/admin/users', () => HttpResponse.json({}, { status: 500 })),
|
||||
);
|
||||
renderPanel();
|
||||
|
||||
await screen.findByText('Type Testing');
|
||||
await waitFor(() => expect(screen.queryAllByRole('combobox')).toHaveLength(0));
|
||||
expect(screen.queryByText('Trip-Scoped Events')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('User-Scoped Events')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ADEV-014: bare arrays are accepted as well as the wrapped payloads', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips', () => HttpResponse.json([{ id: 55, title: 'Bare Trip' }])),
|
||||
http.get('/api/admin/users', () =>
|
||||
HttpResponse.json([{ id: 66, username: 'bare', email: 'bare@example.com' }]),
|
||||
),
|
||||
);
|
||||
renderPanel();
|
||||
await screen.findByText('Trip-Scoped Events');
|
||||
|
||||
const [tripSelect, userSelect] = screen.getAllByRole('combobox') as HTMLSelectElement[];
|
||||
expect(tripSelect.value).toBe('55');
|
||||
expect(screen.getByText('Bare Trip')).toBeInTheDocument();
|
||||
expect(userSelect.value).toBe('66');
|
||||
expect(screen.getByText('bare (bare@example.com)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ADEV-015: without trips or a signed-in user the invite falls back to defaults', async () => {
|
||||
const bodies = captureSends();
|
||||
server.use(http.get('/api/trips', () => HttpResponse.json({ trips: [] })));
|
||||
resetAllStores();
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
await screen.findByText('User-Scoped Events');
|
||||
|
||||
await clickButton(user, 'trip_invite');
|
||||
|
||||
await waitFor(() => expect(bodies).toHaveLength(1));
|
||||
expect(bodies[0]).toMatchObject({
|
||||
event: 'trip_invite',
|
||||
params: { actor: 'Admin', trip: 'Test Trip', tripId: '0' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
// FE-MOB-AGH-001 to FE-MOB-AGH-018
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render, screen, waitFor } from '../../../helpers/render';
|
||||
import { server } from '../../../helpers/msw/server';
|
||||
import { resetAllStores } from '../../../helpers/store';
|
||||
import MAdminGitHubPanel from '../../../../src/mobile/screens/admin/MAdminGitHubPanel';
|
||||
|
||||
interface ReleaseOverrides {
|
||||
id?: number;
|
||||
tag_name?: string;
|
||||
name?: string | null;
|
||||
body?: string | null;
|
||||
published_at?: string | null;
|
||||
created_at?: string;
|
||||
prerelease?: boolean;
|
||||
author?: { login: string } | null;
|
||||
}
|
||||
|
||||
function buildRelease(overrides: ReleaseOverrides = {}) {
|
||||
return {
|
||||
id: 1,
|
||||
tag_name: 'v1.0.0',
|
||||
name: 'Initial Release',
|
||||
body: null,
|
||||
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: 3 }, (_, i) => buildRelease({ id: 100 + i, tag_name: `v0.${i}.0` }));
|
||||
|
||||
function releasesRespondWith(payload: unknown) {
|
||||
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json(payload)));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
releasesRespondWith([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
async function renderPanel(props: { isPrerelease?: boolean } = {}) {
|
||||
const view = render(<MAdminGitHubPanel {...props} />);
|
||||
await waitFor(() => expect(document.querySelector('.animate-spin')).not.toBeInTheDocument());
|
||||
return view;
|
||||
}
|
||||
|
||||
describe('MAdminGitHubPanel', () => {
|
||||
it('FE-MOB-AGH-001: renders the six support cards with their external hrefs', async () => {
|
||||
await renderPanel();
|
||||
|
||||
expect(screen.getByText('Ko-fi').closest('a')).toHaveAttribute('href', 'https://ko-fi.com/mauriceboe');
|
||||
expect(screen.getByText('Buy Me a Coffee').closest('a')).toHaveAttribute(
|
||||
'href',
|
||||
'https://buymeacoffee.com/mauriceboe',
|
||||
);
|
||||
const discord = screen.getByText('Discord').closest('a')!;
|
||||
expect(discord).toHaveAttribute('href', 'https://discord.gg/NhZBDSd4qW');
|
||||
expect(discord).toHaveAttribute('target', '_blank');
|
||||
expect(discord).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
expect(screen.getByText('Report a Bug')).toBeInTheDocument();
|
||||
expect(screen.getByText('Feature Request')).toBeInTheDocument();
|
||||
expect(screen.getByText('Wiki').closest('a')).toHaveAttribute('href', 'https://github.com/mauriceboe/TREK/wiki');
|
||||
expect(screen.getAllByText('Helps me keep building TREK')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-MOB-AGH-002: shows a spinner while the releases request is in flight', () => {
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', async () => {
|
||||
await new Promise(() => {});
|
||||
return HttpResponse.json([]);
|
||||
}),
|
||||
);
|
||||
render(<MAdminGitHubPanel />);
|
||||
|
||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Release History')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AGH-003: renders the error card with the axios message when the request fails', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', () => HttpResponse.json({ message: 'boom' }, { status: 500 })),
|
||||
);
|
||||
render(<MAdminGitHubPanel />);
|
||||
|
||||
await screen.findByText('Failed to load releases');
|
||||
expect(screen.getByText(/500/)).toBeInTheDocument();
|
||||
expect(screen.queryByText('Release History')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AGH-004: renders the timeline header with the repo subtitle and the GitHub link', async () => {
|
||||
releasesRespondWith([buildRelease()]);
|
||||
await renderPanel();
|
||||
|
||||
expect(screen.getByText('Release History')).toBeInTheDocument();
|
||||
expect(screen.getByText('Latest updates from mauriceboe/TREK')).toBeInTheDocument();
|
||||
expect(screen.getByText('GitHub').closest('a')).toHaveAttribute(
|
||||
'href',
|
||||
'https://github.com/mauriceboe/TREK/releases',
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-MOB-AGH-005: marks only the first release as latest and shows the release name and author', async () => {
|
||||
releasesRespondWith([
|
||||
buildRelease({ id: 1, tag_name: 'v2.0.0', name: 'Big Bang' }),
|
||||
buildRelease({ id: 2, tag_name: 'v1.9.0', name: 'v1.9.0' }),
|
||||
]);
|
||||
await renderPanel();
|
||||
|
||||
expect(screen.getAllByText('Latest')).toHaveLength(1);
|
||||
expect(screen.getByText('v2.0.0')).toBeInTheDocument();
|
||||
// name === tag_name on the second release, so it is not repeated
|
||||
expect(screen.getByText('Big Bang')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('v1.9.0')).toHaveLength(1);
|
||||
expect(screen.getAllByText('by mauriceboe')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-MOB-AGH-006: falls back to created_at when published_at is null and hides a missing author', async () => {
|
||||
releasesRespondWith([
|
||||
buildRelease({ published_at: null, created_at: '2024-03-02T08:00:00Z', author: null, name: null }),
|
||||
]);
|
||||
await renderPanel();
|
||||
|
||||
expect(screen.getByText('Mar 2, 2024')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/by /)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AGH-007: hides prereleases unless the panel runs in prerelease mode', async () => {
|
||||
releasesRespondWith([
|
||||
buildRelease({ id: 1, tag_name: 'v3.0.0-beta.1', prerelease: true }),
|
||||
buildRelease({ id: 2, tag_name: 'v2.9.0' }),
|
||||
]);
|
||||
await renderPanel();
|
||||
|
||||
expect(screen.queryByText('v3.0.0-beta.1')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('v2.9.0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AGH-008: prerelease mode shows the pre-release badge', async () => {
|
||||
releasesRespondWith([buildRelease({ id: 1, tag_name: 'v3.0.0-beta.1', prerelease: true })]);
|
||||
await renderPanel({ isPrerelease: true });
|
||||
|
||||
expect(screen.getByText('v3.0.0-beta.1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pre-release')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AGH-009: no toggle is offered for a release without a body', async () => {
|
||||
releasesRespondWith([buildRelease({ body: null })]);
|
||||
await renderPanel();
|
||||
|
||||
expect(screen.queryByText('Show details')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AGH-010: expands and collapses the release notes', async () => {
|
||||
releasesRespondWith([buildRelease({ body: '- Fixed a bug' })]);
|
||||
const user = userEvent.setup();
|
||||
await renderPanel();
|
||||
|
||||
expect(screen.queryByText('Fixed a bug')).not.toBeInTheDocument();
|
||||
await user.click(screen.getByText('Show details'));
|
||||
expect(await screen.findByText('Fixed a bug')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByText('Hide details'));
|
||||
await waitFor(() => expect(screen.queryByText('Fixed a bug')).not.toBeInTheDocument());
|
||||
expect(screen.getByText('Show details')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AGH-011: renders headings, list items and paragraphs from the markdown body', async () => {
|
||||
releasesRespondWith([
|
||||
buildRelease({
|
||||
body: '## Highlights\n- first item\n- second item\n\n### Details\nA plain paragraph.',
|
||||
}),
|
||||
]);
|
||||
const user = userEvent.setup();
|
||||
await renderPanel();
|
||||
await user.click(screen.getByText('Show details'));
|
||||
|
||||
expect((await screen.findByText('Highlights')).tagName).toBe('H3');
|
||||
expect(screen.getByText('Details').tagName).toBe('H4');
|
||||
expect(screen.getByText('first item').closest('li')).toBeInTheDocument();
|
||||
expect(screen.getByText('second item').closest('li')).toBeInTheDocument();
|
||||
expect(screen.getByText('A plain paragraph.').tagName).toBe('P');
|
||||
});
|
||||
|
||||
it('FE-MOB-AGH-012: renders bold and inline code inside list items', async () => {
|
||||
releasesRespondWith([buildRelease({ body: '- **bold text**\n- `inline code`' })]);
|
||||
const user = userEvent.setup();
|
||||
await renderPanel();
|
||||
await user.click(screen.getByText('Show details'));
|
||||
|
||||
expect((await screen.findByText('bold text')).tagName).toBe('STRONG');
|
||||
expect(screen.getByText('inline code').tagName).toBe('CODE');
|
||||
});
|
||||
|
||||
it('FE-MOB-AGH-013: keeps http links and rewrites unsafe link targets to #', async () => {
|
||||
releasesRespondWith([
|
||||
buildRelease({ body: '- [click here](https://example.com)\n- [evil](javascript:alert(1))' }),
|
||||
]);
|
||||
const user = userEvent.setup();
|
||||
await renderPanel();
|
||||
await user.click(screen.getByText('Show details'));
|
||||
|
||||
const safe = await screen.findByText('click here');
|
||||
expect(safe.tagName).toBe('A');
|
||||
expect(safe).toHaveAttribute('href', 'https://example.com');
|
||||
expect(safe).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
expect(screen.getByText('evil')).toHaveAttribute('href', '#');
|
||||
});
|
||||
|
||||
it('FE-MOB-AGH-014: escapes html in the release body instead of rendering it', async () => {
|
||||
releasesRespondWith([buildRelease({ body: 'plain <img src=x onerror="alert(1)"> text' })]);
|
||||
const user = userEvent.setup();
|
||||
await renderPanel();
|
||||
await user.click(screen.getByText('Show details'));
|
||||
|
||||
expect(await screen.findByText('plain <img src=x onerror="alert(1)"> text')).toBeInTheDocument();
|
||||
expect(document.querySelector('img')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AGH-015: hides "Load more" when the first page is not full', async () => {
|
||||
releasesRespondWith(PAGE_2);
|
||||
await renderPanel();
|
||||
|
||||
expect(screen.getByText('v0.0.0')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Load more')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AGH-016: "Load more" appends the second page and then disappears', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', ({ request }) => {
|
||||
const page = new URL(request.url).searchParams.get('page');
|
||||
return HttpResponse.json(page === '2' ? PAGE_2 : PAGE_1);
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
await renderPanel();
|
||||
|
||||
expect(screen.getAllByText(/^v1\.\d\.0$/)).toHaveLength(10);
|
||||
await user.click(screen.getByText('Load more'));
|
||||
|
||||
expect(await screen.findByText('v0.0.0')).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/^v[01]\.\d\.0$/)).toHaveLength(13);
|
||||
expect(screen.queryByText('Load more')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AGH-017: a failing "Load more" swaps the timeline for the error card', async () => {
|
||||
let calls = 0;
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', () => {
|
||||
calls += 1;
|
||||
if (calls === 1) return HttpResponse.json(PAGE_1);
|
||||
return HttpResponse.json({ message: 'nope' }, { status: 500 });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
await renderPanel();
|
||||
|
||||
await user.click(screen.getByText('Load more'));
|
||||
await screen.findByText('Failed to load releases');
|
||||
});
|
||||
|
||||
it('FE-MOB-AGH-018: a non-array response is treated as an empty release list', async () => {
|
||||
releasesRespondWith({ message: 'rate limited' });
|
||||
await renderPanel();
|
||||
|
||||
expect(screen.getByText('Release History')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Load more')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Latest')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,293 @@
|
||||
// FE-MOB-MCPTOK-001 to FE-MOB-MCPTOK-014
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { render, screen, waitFor } from '../../../helpers/render';
|
||||
import { server } from '../../../helpers/msw/server';
|
||||
import { resetAllStores } from '../../../helpers/store';
|
||||
import { ToastContainer } from '../../../../src/components/shared/Toast';
|
||||
import MAdminMcpTokensPanel from '../../../../src/mobile/screens/admin/MAdminMcpTokensPanel';
|
||||
|
||||
const TOKEN_1 = {
|
||||
id: 1,
|
||||
name: 'CI Token',
|
||||
token_prefix: 'trek_abc',
|
||||
created_at: '2025-01-15T00:00:00Z',
|
||||
last_used_at: null,
|
||||
user_id: 10,
|
||||
username: 'alice',
|
||||
};
|
||||
|
||||
const TOKEN_2 = {
|
||||
id: 2,
|
||||
name: 'Ops Token',
|
||||
token_prefix: 'trek_xyz',
|
||||
created_at: '2025-03-01T00:00:00Z',
|
||||
last_used_at: '2025-04-01T00:00:00Z',
|
||||
user_id: 11,
|
||||
username: 'bob',
|
||||
};
|
||||
|
||||
function buildSession(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 1,
|
||||
client_id: 'cid',
|
||||
client_name: 'Claude Desktop',
|
||||
user_id: 10,
|
||||
username: 'alice',
|
||||
scopes: ['trips:read', 'budget:read'],
|
||||
access_token_expires_at: '2025-02-01T00:00:00Z',
|
||||
refresh_token_expires_at: '2025-03-01T00:00:00Z',
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderPanel() {
|
||||
return render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<MAdminMcpTokensPanel />
|
||||
</>,
|
||||
);
|
||||
}
|
||||
|
||||
// The trash icons carry title="Delete" too — the confirm sheet's button is the
|
||||
// one without a title attribute.
|
||||
function confirmButton(label: string): HTMLElement {
|
||||
const candidates = screen.getAllByRole('button', { name: label });
|
||||
const withoutTitle = candidates.find((b) => !b.getAttribute('title'));
|
||||
return withoutTitle ?? candidates[candidates.length - 1];
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('MAdminMcpTokensPanel', () => {
|
||||
it('FE-MOB-MCPTOK-001: renders the section header and both sub-section titles', async () => {
|
||||
renderPanel();
|
||||
|
||||
expect(screen.getByText('MCP Access')).toBeInTheDocument();
|
||||
expect(screen.getByText('Manage OAuth sessions and API tokens across all users')).toBeInTheDocument();
|
||||
expect(screen.getByText('OAuth Sessions')).toBeInTheDocument();
|
||||
expect(screen.getByText('API Tokens')).toBeInTheDocument();
|
||||
await screen.findByText('No MCP tokens have been created yet');
|
||||
});
|
||||
|
||||
it('FE-MOB-MCPTOK-002: shows spinners while both requests are in flight', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/mcp-tokens', async () => {
|
||||
await new Promise(() => {});
|
||||
return HttpResponse.json({ tokens: [] });
|
||||
}),
|
||||
http.get('/api/admin/oauth-sessions', async () => {
|
||||
await new Promise(() => {});
|
||||
return HttpResponse.json({ sessions: [] });
|
||||
}),
|
||||
);
|
||||
renderPanel();
|
||||
|
||||
expect(document.querySelectorAll('.animate-spin')).toHaveLength(2);
|
||||
expect(screen.queryByText('No MCP tokens have been created yet')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MCPTOK-003: renders both empty states when nothing is configured', async () => {
|
||||
renderPanel();
|
||||
|
||||
await screen.findByText('No active OAuth sessions');
|
||||
expect(screen.getByText('No MCP tokens have been created yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MCPTOK-004: token rows show name, prefix, owner and last-used date', async () => {
|
||||
server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })));
|
||||
renderPanel();
|
||||
|
||||
await screen.findByText('CI Token');
|
||||
expect(screen.getByText('Ops Token')).toBeInTheDocument();
|
||||
expect(screen.getByText('alice')).toBeInTheDocument();
|
||||
expect(screen.getByText('bob')).toBeInTheDocument();
|
||||
expect(screen.getByText(/trek_abc/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/trek_xyz/)).toBeInTheDocument();
|
||||
// last_used_at null → "Never", otherwise the formatted date
|
||||
expect(screen.getByText('Never')).toBeInTheDocument();
|
||||
expect(screen.getByText('4/1/2025')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MCPTOK-005: session rows show client, user and scope chips', async () => {
|
||||
server.use(http.get('/api/admin/oauth-sessions', () => HttpResponse.json({ sessions: [buildSession()] })));
|
||||
renderPanel();
|
||||
|
||||
await screen.findByText('Claude Desktop');
|
||||
expect(screen.getByText('alice')).toBeInTheDocument();
|
||||
expect(screen.getByText('trips:read')).toBeInTheDocument();
|
||||
expect(screen.getByText('budget:read')).toBeInTheDocument();
|
||||
expect(screen.getByText('1/1/2025')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MCPTOK-006: scopes beyond the preview stay hidden until expanded', 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/admin/oauth-sessions', () => HttpResponse.json({ sessions: [buildSession({ scopes })] })),
|
||||
);
|
||||
renderPanel();
|
||||
|
||||
await screen.findByText('Claude Desktop');
|
||||
expect(screen.queryByText('packing:read')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '+1 more' }));
|
||||
expect(screen.getByText('packing:read')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'show less' }));
|
||||
expect(screen.queryByText('packing:read')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MCPTOK-007: no expand button when scopes fit into the preview', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/oauth-sessions', () =>
|
||||
HttpResponse.json({ sessions: [buildSession({ scopes: ['trips:read'] })] }),
|
||||
),
|
||||
);
|
||||
renderPanel();
|
||||
|
||||
await screen.findByText('Claude Desktop');
|
||||
expect(screen.queryByText(/more$/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('show less')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MCPTOK-008: token trash opens the delete confirm sheet', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1] })));
|
||||
renderPanel();
|
||||
|
||||
await screen.findByText('CI Token');
|
||||
await user.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'Delete Token' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('This will revoke the token immediately. The user will lose MCP access through this token.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MCPTOK-009: cancelling the confirm sheet keeps the token', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1] })));
|
||||
renderPanel();
|
||||
|
||||
await screen.findByText('CI Token');
|
||||
await user.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
expect(screen.getByText('CI Token')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MCPTOK-010: confirming removes the token and toasts', async () => {
|
||||
const user = userEvent.setup();
|
||||
let deletedId = '';
|
||||
server.use(
|
||||
http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })),
|
||||
http.delete('/api/admin/mcp-tokens/:id', ({ params }) => {
|
||||
deletedId = String(params.id);
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
renderPanel();
|
||||
|
||||
await screen.findByText('CI Token');
|
||||
await user.click(screen.getAllByRole('button', { name: 'Delete' })[0]);
|
||||
await user.click(confirmButton('Delete'));
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('CI Token')).not.toBeInTheDocument());
|
||||
expect(deletedId).toBe('1');
|
||||
expect(screen.getByText('Ops Token')).toBeInTheDocument();
|
||||
await screen.findByText('Token deleted');
|
||||
});
|
||||
|
||||
it('FE-MOB-MCPTOK-011: a failing delete keeps the token and shows the error toast', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1] })),
|
||||
http.delete('/api/admin/mcp-tokens/:id', () => HttpResponse.json({ error: 'nope' }, { status: 403 })),
|
||||
);
|
||||
renderPanel();
|
||||
|
||||
await screen.findByText('CI Token');
|
||||
await user.click(screen.getAllByRole('button', { name: 'Delete' })[0]);
|
||||
await user.click(confirmButton('Delete'));
|
||||
|
||||
await screen.findByText('Failed to delete token');
|
||||
expect(screen.getByText('CI Token')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MCPTOK-012: revoking a session removes it and toasts', async () => {
|
||||
const user = userEvent.setup();
|
||||
let revokedId = '';
|
||||
server.use(
|
||||
http.get('/api/admin/oauth-sessions', () =>
|
||||
HttpResponse.json({ sessions: [buildSession({ id: 5, client_name: 'Revoke Me' })] }),
|
||||
),
|
||||
http.delete('/api/admin/oauth-sessions/:id', ({ params }) => {
|
||||
revokedId = String(params.id);
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
renderPanel();
|
||||
|
||||
await screen.findByText('Revoke Me');
|
||||
await user.click(screen.getAllByRole('button', { name: 'Delete' })[0]);
|
||||
expect(screen.getByRole('dialog', { name: 'Revoke Session' })).toBeInTheDocument();
|
||||
|
||||
// Cancelling leaves the session untouched; reopening still works.
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
expect(revokedId).toBe('');
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: 'Delete' })[0]);
|
||||
await user.click(confirmButton('Delete'));
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Revoke Me')).not.toBeInTheDocument());
|
||||
expect(revokedId).toBe('5');
|
||||
await screen.findByText('Session revoked');
|
||||
});
|
||||
|
||||
it('FE-MOB-MCPTOK-013: a failing revoke keeps the session and shows the error toast', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/oauth-sessions', () =>
|
||||
HttpResponse.json({ sessions: [buildSession({ id: 6, client_name: 'Error Session' })] }),
|
||||
),
|
||||
http.delete('/api/admin/oauth-sessions/:id', () => HttpResponse.json({ error: 'nope' }, { status: 403 })),
|
||||
);
|
||||
renderPanel();
|
||||
|
||||
await screen.findByText('Error Session');
|
||||
await user.click(screen.getAllByRole('button', { name: 'Delete' })[0]);
|
||||
await user.click(confirmButton('Delete'));
|
||||
|
||||
await screen.findByText('Failed to revoke session');
|
||||
expect(screen.getByText('Error Session')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MCPTOK-014: both load failures toast and fall back to the empty states', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
|
||||
http.get('/api/admin/oauth-sessions', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
|
||||
);
|
||||
renderPanel();
|
||||
|
||||
await screen.findByText('Failed to load tokens');
|
||||
expect(screen.getByText('Failed to load OAuth sessions')).toBeInTheDocument();
|
||||
expect(screen.getByText('No MCP tokens have been created yet')).toBeInTheDocument();
|
||||
expect(screen.getByText('No active OAuth sessions')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,540 @@
|
||||
// FE-MOB-ANOTIF-001 to FE-MOB-ANOTIF-031
|
||||
import { useState } from 'react';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render, screen, waitFor } from '../../../helpers/render';
|
||||
import { server } from '../../../helpers/msw/server';
|
||||
import { resetAllStores } from '../../../helpers/store';
|
||||
import { useTranslation } from '../../../../src/i18n';
|
||||
import type { useAdmin } from '../../../../src/pages/admin/useAdmin';
|
||||
import MAdminNotificationsSection from '../../../../src/mobile/screens/admin/MAdminNotificationsSection';
|
||||
|
||||
// The per-event matrix loads its own data and has its own spec — stub it so the
|
||||
// assertions here only see the section's own cards.
|
||||
vi.mock('../../../../src/mobile/screens/admin/MAdminNotifyMatrix', () => ({
|
||||
default: () => <div data-testid="notify-matrix" />,
|
||||
}));
|
||||
|
||||
const MASK = '••••••••';
|
||||
|
||||
interface HarnessProps {
|
||||
initial?: Record<string, string>;
|
||||
smtpLoaded?: boolean;
|
||||
toast?: { success: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
|
||||
setTripRemindersEnabled?: (value: boolean) => void;
|
||||
}
|
||||
|
||||
function buildToast() {
|
||||
return { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() };
|
||||
}
|
||||
|
||||
// The section is a controlled child of useAdmin — this harness owns the same
|
||||
// smtpValues state so setSmtpValues updaters really re-render the card.
|
||||
function Harness({ initial = {}, smtpLoaded = true, toast = buildToast(), setTripRemindersEnabled = vi.fn() }: HarnessProps) {
|
||||
const { t } = useTranslation();
|
||||
const [smtpValues, setSmtpValues] = useState<Record<string, string>>(initial);
|
||||
const admin = {
|
||||
toast,
|
||||
smtpValues,
|
||||
setSmtpValues,
|
||||
smtpLoaded,
|
||||
setTripRemindersEnabled,
|
||||
} as unknown as ReturnType<typeof useAdmin>;
|
||||
return <MAdminNotificationsSection admin={admin} t={t} />;
|
||||
}
|
||||
|
||||
/** Records the body of every PUT /auth/app-settings the section sends. */
|
||||
function captureAppSettings() {
|
||||
const bodies: Record<string, unknown>[] = [];
|
||||
server.use(
|
||||
http.put('/api/auth/app-settings', async ({ request }) => {
|
||||
bodies.push((await request.json()) as Record<string, unknown>);
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
return bodies;
|
||||
}
|
||||
|
||||
function failAppSettings() {
|
||||
server.use(http.put('/api/auth/app-settings', () => HttpResponse.json({}, { status: 500 })));
|
||||
}
|
||||
|
||||
/** The two password inputs are, in DOM order, the SMTP password and the ntfy token. */
|
||||
function ntfyTokenInput() {
|
||||
return Array.from(document.querySelectorAll<HTMLInputElement>('input[type="password"]'))[1];
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('MAdminNotificationsSection', () => {
|
||||
it('FE-MOB-ANOTIF-001: renders every channel card and prefills the SMTP fields', () => {
|
||||
render(
|
||||
<Harness
|
||||
initial={{ notification_channels: 'email', smtp_host: 'mail.test', smtp_port: '2525' }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Email (SMTP)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Webhook')).toBeInTheDocument();
|
||||
expect(screen.getByText('Ntfy')).toBeInTheDocument();
|
||||
expect(screen.getByText('In-App')).toBeInTheDocument();
|
||||
expect(screen.getByText('Trip Reminders')).toBeInTheDocument();
|
||||
expect(screen.getByText('Admin Webhook')).toBeInTheDocument();
|
||||
expect(screen.getByText('Admin Ntfy')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('notify-matrix')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByPlaceholderText('mail.example.com')).toHaveValue('mail.test');
|
||||
expect(screen.getByPlaceholderText('587')).toHaveValue('2525');
|
||||
expect(screen.getByRole('switch', { name: 'Email (SMTP)' })).toHaveAttribute('aria-checked', 'true');
|
||||
expect(screen.getByRole('switch', { name: 'In-App' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-MOB-ANOTIF-002: hides the credential fields until the settings have loaded', () => {
|
||||
render(<Harness smtpLoaded={false} initial={{ smtp_host: 'mail.test' }} />);
|
||||
|
||||
expect(screen.queryByPlaceholderText('mail.example.com')).not.toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText('https://ntfy.sh')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Email (SMTP)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ANOTIF-003: derives the active channels from the plural setting', () => {
|
||||
render(<Harness initial={{ notification_channels: 'webhook,ntfy' }} />);
|
||||
|
||||
expect(screen.getByRole('switch', { name: 'Email (SMTP)' })).toHaveAttribute('aria-checked', 'false');
|
||||
expect(screen.getByRole('switch', { name: 'Webhook' })).toHaveAttribute('aria-checked', 'true');
|
||||
expect(screen.getByRole('switch', { name: 'Ntfy' })).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
it('FE-MOB-ANOTIF-004: falls back to the legacy singular setting', () => {
|
||||
render(<Harness initial={{ notification_channel: 'email' }} />);
|
||||
|
||||
expect(screen.getByRole('switch', { name: 'Email (SMTP)' })).toHaveAttribute('aria-checked', 'true');
|
||||
expect(screen.getByRole('switch', { name: 'Webhook' })).toHaveAttribute('aria-checked', 'false');
|
||||
});
|
||||
|
||||
it('FE-MOB-ANOTIF-005: treats a missing setting as no active channel', () => {
|
||||
render(<Harness />);
|
||||
|
||||
expect(screen.getByRole('switch', { name: 'Email (SMTP)' })).toHaveAttribute('aria-checked', 'false');
|
||||
expect(screen.getByRole('switch', { name: 'Ntfy' })).toHaveAttribute('aria-checked', 'false');
|
||||
});
|
||||
|
||||
it('FE-MOB-ANOTIF-006: enabling a channel saves the CSV and keeps unknown channel ids', async () => {
|
||||
const bodies = captureAppSettings();
|
||||
const user = userEvent.setup();
|
||||
render(<Harness initial={{ notification_channels: 'webhook,pushover' }} />);
|
||||
|
||||
await user.click(screen.getByRole('switch', { name: 'Email (SMTP)' }));
|
||||
|
||||
await waitFor(() => expect(bodies).toHaveLength(1));
|
||||
expect(bodies[0]).toEqual({ notification_channels: 'email,webhook,pushover' });
|
||||
expect(screen.getByRole('switch', { name: 'Email (SMTP)' })).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
it('FE-MOB-ANOTIF-007: disabling the last channel stores "none"', async () => {
|
||||
const bodies = captureAppSettings();
|
||||
const user = userEvent.setup();
|
||||
render(<Harness initial={{ notification_channels: 'ntfy' }} />);
|
||||
|
||||
await user.click(screen.getByRole('switch', { name: 'Ntfy' }));
|
||||
|
||||
await waitFor(() => expect(bodies).toHaveLength(1));
|
||||
expect(bodies[0]).toEqual({ notification_channels: 'none' });
|
||||
});
|
||||
|
||||
it('FE-MOB-ANOTIF-008: a failed channel save rolls the toggle back and reports the error', async () => {
|
||||
failAppSettings();
|
||||
const toast = buildToast();
|
||||
const user = userEvent.setup();
|
||||
render(<Harness initial={{ notification_channels: 'email' }} toast={toast} />);
|
||||
|
||||
await user.click(screen.getByRole('switch', { name: 'Webhook' }));
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Error'));
|
||||
expect(screen.getByRole('switch', { name: 'Webhook' })).toHaveAttribute('aria-checked', 'false');
|
||||
expect(screen.getByRole('switch', { name: 'Email (SMTP)' })).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
it('FE-MOB-ANOTIF-009: editing an SMTP field and saving sends only the credential keys', async () => {
|
||||
const bodies = captureAppSettings();
|
||||
const toast = buildToast();
|
||||
const setTripRemindersEnabled = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/auth/app-config', () => HttpResponse.json({ trip_reminders_enabled: true })),
|
||||
);
|
||||
render(
|
||||
<Harness
|
||||
initial={{ notification_channels: 'email', smtp_host: 'mail.test', admin_ntfy_topic: 'ops' }}
|
||||
toast={toast}
|
||||
setTripRemindersEnabled={setTripRemindersEnabled}
|
||||
/>,
|
||||
);
|
||||
|
||||
// smtp_user and smtp_from share a placeholder — the first one is the user field
|
||||
await user.type(screen.getAllByPlaceholderText('trek@example.com')[0], 'bot');
|
||||
await user.click(screen.getAllByRole('button', { name: 'Save' })[0]);
|
||||
|
||||
await waitFor(() => expect(bodies).toHaveLength(1));
|
||||
expect(bodies[0]).toEqual({ smtp_host: 'mail.test', smtp_user: 'bot' });
|
||||
expect(toast.success).toHaveBeenCalledWith('Notification settings saved');
|
||||
// The follow-up app-config read syncs the reminder flag back into the store
|
||||
await waitFor(() => expect(setTripRemindersEnabled).toHaveBeenCalledWith(true));
|
||||
});
|
||||
|
||||
it('FE-MOB-ANOTIF-010: a failed SMTP save reports the error', async () => {
|
||||
failAppSettings();
|
||||
const toast = buildToast();
|
||||
const user = userEvent.setup();
|
||||
render(<Harness initial={{ smtp_host: 'mail.test' }} toast={toast} />);
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: 'Save' })[0]);
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Error'));
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-ANOTIF-011: the TLS toggle flips smtp_skip_tls_verify', async () => {
|
||||
const bodies = captureAppSettings();
|
||||
const user = userEvent.setup();
|
||||
render(<Harness initial={{ notification_channels: 'email', smtp_host: 'mail.test' }} />);
|
||||
|
||||
const tls = screen.getByRole('switch', { name: 'Skip TLS certificate check' });
|
||||
expect(tls).toHaveAttribute('aria-checked', 'false');
|
||||
await user.click(tls);
|
||||
expect(tls).toHaveAttribute('aria-checked', 'true');
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: 'Save' })[0]);
|
||||
await waitFor(() => expect(bodies).toHaveLength(1));
|
||||
expect(bodies[0]).toMatchObject({ smtp_skip_tls_verify: 'true' });
|
||||
|
||||
await user.click(tls);
|
||||
expect(tls).toHaveAttribute('aria-checked', 'false');
|
||||
await user.click(screen.getAllByRole('button', { name: 'Save' })[0]);
|
||||
await waitFor(() => expect(bodies).toHaveLength(2));
|
||||
expect(bodies[1]).toMatchObject({ smtp_skip_tls_verify: 'false' });
|
||||
});
|
||||
|
||||
it('FE-MOB-ANOTIF-012: the test button is disabled until a host is configured', () => {
|
||||
render(<Harness initial={{ smtp_host: ' ' }} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Send test email' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-MOB-ANOTIF-013: the SMTP test persists the credentials first and reports success', async () => {
|
||||
const bodies = captureAppSettings();
|
||||
const toast = buildToast();
|
||||
const user = userEvent.setup();
|
||||
server.use(http.post('/api/notifications/test-smtp', () => HttpResponse.json({ success: true })));
|
||||
render(<Harness initial={{ smtp_host: 'mail.test', smtp_from: 'trek@test' }} toast={toast} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Send test email' }));
|
||||
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Test email sent successfully'));
|
||||
expect(bodies[0]).toEqual({ smtp_host: 'mail.test', smtp_from: 'trek@test' });
|
||||
});
|
||||
|
||||
it('FE-MOB-ANOTIF-014: an unsuccessful SMTP test surfaces the server error text', async () => {
|
||||
const toast = buildToast();
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/notifications/test-smtp', () =>
|
||||
HttpResponse.json({ success: false, error: 'auth rejected' }),
|
||||
),
|
||||
);
|
||||
render(<Harness initial={{ smtp_host: 'mail.test' }} toast={toast} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Send test email' }));
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('auth rejected'));
|
||||
|
||||
// Without an error field the generic message is used
|
||||
server.use(http.post('/api/notifications/test-smtp', () => HttpResponse.json({ success: false })));
|
||||
await user.click(screen.getByRole('button', { name: 'Send test email' }));
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Test email failed'));
|
||||
});
|
||||
|
||||
it('FE-MOB-ANOTIF-015: a rejected SMTP test falls back to the generic failure text', async () => {
|
||||
const toast = buildToast();
|
||||
const user = userEvent.setup();
|
||||
server.use(http.post('/api/notifications/test-smtp', () => HttpResponse.json({}, { status: 500 })));
|
||||
render(<Harness initial={{ smtp_host: 'mail.test' }} toast={toast} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Send test email' }));
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Test email failed'));
|
||||
});
|
||||
|
||||
it('FE-MOB-ANOTIF-016: trip reminders default to on and can be switched off', async () => {
|
||||
const bodies = captureAppSettings();
|
||||
const toast = buildToast();
|
||||
const setTripRemindersEnabled = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/auth/app-config', () => HttpResponse.json({ trip_reminders_enabled: false })),
|
||||
);
|
||||
render(<Harness toast={toast} setTripRemindersEnabled={setTripRemindersEnabled} />);
|
||||
|
||||
const toggle = screen.getByRole('switch', { name: 'Trip Reminders' });
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'true');
|
||||
await user.click(toggle);
|
||||
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Trip reminders disabled'));
|
||||
expect(bodies[0]).toEqual({ notify_trip_reminder: 'false' });
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'false');
|
||||
await waitFor(() => expect(setTripRemindersEnabled).toHaveBeenCalledWith(false));
|
||||
});
|
||||
|
||||
it('FE-MOB-ANOTIF-017: switching trip reminders back on reports the enabled toast', async () => {
|
||||
const bodies = captureAppSettings();
|
||||
const toast = buildToast();
|
||||
const user = userEvent.setup();
|
||||
render(<Harness initial={{ notify_trip_reminder: 'false' }} toast={toast} />);
|
||||
|
||||
await user.click(screen.getByRole('switch', { name: 'Trip Reminders' }));
|
||||
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Trip reminders enabled'));
|
||||
expect(bodies[0]).toEqual({ notify_trip_reminder: 'true' });
|
||||
});
|
||||
|
||||
it('FE-MOB-ANOTIF-018: a failed trip reminder save rolls the toggle back', async () => {
|
||||
failAppSettings();
|
||||
const toast = buildToast();
|
||||
const user = userEvent.setup();
|
||||
render(<Harness toast={toast} />);
|
||||
|
||||
await user.click(screen.getByRole('switch', { name: 'Trip Reminders' }));
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Error'));
|
||||
expect(screen.getByRole('switch', { name: 'Trip Reminders' })).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
it('FE-MOB-ANOTIF-019: the admin webhook URL is saved and tested', async () => {
|
||||
const bodies = captureAppSettings();
|
||||
const toast = buildToast();
|
||||
const user = userEvent.setup();
|
||||
let testedUrl: string | undefined;
|
||||
server.use(
|
||||
http.post('/api/notifications/test-webhook', async ({ request }) => {
|
||||
testedUrl = ((await request.json()) as { url?: string }).url;
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
render(<Harness toast={toast} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('https://discord.com/api/webhooks/...');
|
||||
await user.type(input, 'https://hooks.test/abc');
|
||||
await user.click(screen.getAllByRole('button', { name: 'Save' })[1]);
|
||||
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Admin webhook URL saved'));
|
||||
expect(bodies[0]).toEqual({ admin_webhook_url: 'https://hooks.test/abc' });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Send test webhook' }));
|
||||
await waitFor(() => expect(testedUrl).toBe('https://hooks.test/abc'));
|
||||
expect(toast.success).toHaveBeenCalledWith('Test webhook sent successfully');
|
||||
});
|
||||
|
||||
it('FE-MOB-ANOTIF-020: a masked admin webhook is tested without resending the URL', async () => {
|
||||
const bodies = captureAppSettings();
|
||||
const toast = buildToast();
|
||||
const user = userEvent.setup();
|
||||
let payload: { url?: string } | undefined;
|
||||
server.use(
|
||||
http.post('/api/notifications/test-webhook', async ({ request }) => {
|
||||
payload = (await request.json()) as { url?: string };
|
||||
return HttpResponse.json({ success: false, error: 'endpoint gone' });
|
||||
}),
|
||||
);
|
||||
render(<Harness initial={{ admin_webhook_url: MASK }} toast={toast} />);
|
||||
|
||||
// The masked value is never echoed into the input, only into the placeholder
|
||||
const maskedInput = screen
|
||||
.getAllByPlaceholderText(MASK)
|
||||
.find((el) => (el as HTMLInputElement).type === 'text')!;
|
||||
expect(maskedInput).toHaveValue('');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Send test webhook' }));
|
||||
await waitFor(() => expect(payload).toBeDefined());
|
||||
// The mask is not a URL, so nothing is re-saved before the probe
|
||||
expect(payload).toEqual({});
|
||||
expect(bodies).toHaveLength(0);
|
||||
expect(toast.error).toHaveBeenCalledWith('endpoint gone');
|
||||
|
||||
server.use(http.post('/api/notifications/test-webhook', () => HttpResponse.json({ success: false })));
|
||||
await user.click(screen.getByRole('button', { name: 'Send test webhook' }));
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Test webhook failed'));
|
||||
});
|
||||
|
||||
it('FE-MOB-ANOTIF-021: admin webhook errors are reported for save and test alike', async () => {
|
||||
const toast = buildToast();
|
||||
const user = userEvent.setup();
|
||||
failAppSettings();
|
||||
server.use(
|
||||
http.post('/api/notifications/test-webhook', () => HttpResponse.json({}, { status: 500 })),
|
||||
);
|
||||
render(<Harness initial={{ admin_webhook_url: 'https://hooks.test/abc' }} toast={toast} />);
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: 'Save' })[1]);
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Error'));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Send test webhook' }));
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Test webhook failed'));
|
||||
});
|
||||
|
||||
it('FE-MOB-ANOTIF-022: the admin webhook test button is disabled without a URL', () => {
|
||||
render(<Harness />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Send test webhook' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Send test ntfy' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-MOB-ANOTIF-023: saving admin ntfy sends server, topic and a freshly typed token', async () => {
|
||||
const bodies = captureAppSettings();
|
||||
const toast = buildToast();
|
||||
const user = userEvent.setup();
|
||||
render(<Harness toast={toast} />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText('https://ntfy.sh'), 'https://ntfy.test');
|
||||
await user.type(screen.getByPlaceholderText('trek-admin-alerts'), 'ops');
|
||||
await user.type(ntfyTokenInput(), 'tk_1');
|
||||
await user.click(screen.getAllByRole('button', { name: 'Save' })[2]);
|
||||
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Admin ntfy settings saved'));
|
||||
expect(bodies[0]).toEqual({
|
||||
admin_ntfy_server: 'https://ntfy.test',
|
||||
admin_ntfy_topic: 'ops',
|
||||
admin_ntfy_token: 'tk_1',
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-MOB-ANOTIF-024: a masked ntfy token is omitted from the save payload', async () => {
|
||||
const bodies = captureAppSettings();
|
||||
const user = userEvent.setup();
|
||||
render(<Harness initial={{ admin_ntfy_topic: 'ops', admin_ntfy_token: MASK }} />);
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: 'Save' })[2]);
|
||||
|
||||
await waitFor(() => expect(bodies).toHaveLength(1));
|
||||
expect(bodies[0]).toEqual({ admin_ntfy_server: '', admin_ntfy_topic: 'ops' });
|
||||
});
|
||||
|
||||
it('FE-MOB-ANOTIF-025: a failed ntfy save reports the error', async () => {
|
||||
failAppSettings();
|
||||
const toast = buildToast();
|
||||
const user = userEvent.setup();
|
||||
render(<Harness initial={{ admin_ntfy_topic: 'ops' }} toast={toast} />);
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: 'Save' })[2]);
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Error'));
|
||||
});
|
||||
|
||||
it('FE-MOB-ANOTIF-026: the ntfy test sends topic, server and the unmasked token', async () => {
|
||||
const toast = buildToast();
|
||||
const user = userEvent.setup();
|
||||
let payload: Record<string, unknown> | undefined;
|
||||
server.use(
|
||||
http.post('/api/notifications/test-ntfy', async ({ request }) => {
|
||||
payload = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<Harness
|
||||
initial={{ admin_ntfy_topic: ' ops ', admin_ntfy_server: 'https://ntfy.test', admin_ntfy_token: 'tk_1' }}
|
||||
toast={toast}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Send test ntfy' }));
|
||||
|
||||
await waitFor(() => expect(payload).toBeDefined());
|
||||
expect(payload).toEqual({ topic: 'ops', server: 'https://ntfy.test', token: 'tk_1' });
|
||||
expect(toast.success).toHaveBeenCalledWith('Test ntfy sent successfully');
|
||||
});
|
||||
|
||||
it('FE-MOB-ANOTIF-027: a masked token is sent as null and a failed test is reported', async () => {
|
||||
const toast = buildToast();
|
||||
const user = userEvent.setup();
|
||||
let payload: Record<string, unknown> | undefined;
|
||||
server.use(
|
||||
http.post('/api/notifications/test-ntfy', async ({ request }) => {
|
||||
payload = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({ success: false, error: 'topic refused' });
|
||||
}),
|
||||
);
|
||||
render(<Harness initial={{ admin_ntfy_topic: 'ops', admin_ntfy_token: MASK }} toast={toast} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Send test ntfy' }));
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('topic refused'));
|
||||
expect(payload).toEqual({ topic: 'ops', server: null, token: null });
|
||||
|
||||
server.use(http.post('/api/notifications/test-ntfy', () => HttpResponse.json({ success: false })));
|
||||
await user.click(screen.getByRole('button', { name: 'Send test ntfy' }));
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Test ntfy failed'));
|
||||
});
|
||||
|
||||
it('FE-MOB-ANOTIF-028: a rejected ntfy test falls back to the generic failure text', async () => {
|
||||
const toast = buildToast();
|
||||
const user = userEvent.setup();
|
||||
server.use(http.post('/api/notifications/test-ntfy', () => HttpResponse.json({}, { status: 500 })));
|
||||
render(<Harness initial={{ admin_ntfy_topic: 'ops' }} toast={toast} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Send test ntfy' }));
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Test ntfy failed'));
|
||||
});
|
||||
|
||||
it('FE-MOB-ANOTIF-029: the Clear button only exists for a stored token and wipes it', async () => {
|
||||
const bodies = captureAppSettings();
|
||||
const toast = buildToast();
|
||||
const user = userEvent.setup();
|
||||
render(<Harness initial={{ admin_ntfy_token: MASK }} toast={toast} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Clear' }));
|
||||
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Admin access token cleared'));
|
||||
expect(bodies[0]).toEqual({ admin_ntfy_token: '' });
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: 'Clear' })).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-MOB-ANOTIF-030: a failed token clear keeps the button and reports the error', async () => {
|
||||
failAppSettings();
|
||||
const toast = buildToast();
|
||||
const user = userEvent.setup();
|
||||
render(<Harness initial={{ admin_ntfy_token: MASK }} toast={toast} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Clear' }));
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Error'));
|
||||
expect(screen.getByRole('button', { name: 'Clear' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ANOTIF-031: a failing app-config refresh does not break the SMTP save', async () => {
|
||||
const toast = buildToast();
|
||||
const setTripRemindersEnabled = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
captureAppSettings();
|
||||
server.use(http.get('/api/auth/app-config', () => HttpResponse.json({}, { status: 500 })));
|
||||
render(
|
||||
<Harness
|
||||
initial={{ smtp_host: 'mail.test' }}
|
||||
toast={toast}
|
||||
setTripRemindersEnabled={setTripRemindersEnabled}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: 'Save' })[0]);
|
||||
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Notification settings saved'));
|
||||
expect(setTripRemindersEnabled).not.toHaveBeenCalled();
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
// FE-MOB-AMATRIX-001 to FE-MOB-AMATRIX-011
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render, screen, waitFor } from '../../../helpers/render';
|
||||
import { server } from '../../../helpers/msw/server';
|
||||
import { resetAllStores } from '../../../helpers/store';
|
||||
import { useTranslation } from '../../../../src/i18n';
|
||||
import type { useToast } from '../../../../src/components/shared/Toast';
|
||||
import MAdminNotifyMatrix from '../../../../src/mobile/screens/admin/MAdminNotifyMatrix';
|
||||
|
||||
const MATRIX = {
|
||||
event_types: ['version_available', 'trip_reminder'],
|
||||
channels: [
|
||||
{ id: 'inapp', active: true },
|
||||
{ id: 'email', active: true },
|
||||
{ id: 'webhook', active: false },
|
||||
{ id: 'ntfy', active: true },
|
||||
],
|
||||
implemented_combos: {
|
||||
version_available: ['inapp', 'email'],
|
||||
trip_reminder: ['inapp'],
|
||||
},
|
||||
preferences: {
|
||||
version_available: { inapp: true, email: false },
|
||||
},
|
||||
};
|
||||
|
||||
function buildToast() {
|
||||
return {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function Harness({ toast }: { toast: ReturnType<typeof useToast> }) {
|
||||
const { t } = useTranslation();
|
||||
return <MAdminNotifyMatrix t={t} toast={toast} />;
|
||||
}
|
||||
|
||||
function matrixRespondsWith(payload: unknown) {
|
||||
server.use(http.get('/api/admin/notification-preferences', () => HttpResponse.json(payload)));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('MAdminNotifyMatrix', () => {
|
||||
it('FE-MOB-AMATRIX-001: shows the loading card until the matrix arrives', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/notification-preferences', async () => {
|
||||
await new Promise(() => {});
|
||||
return HttpResponse.json(MATRIX);
|
||||
}),
|
||||
);
|
||||
render(<Harness toast={buildToast()} />);
|
||||
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AMATRIX-002: stays on the loading card when the request fails', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/notification-preferences', () => HttpResponse.json({}, { status: 500 })),
|
||||
);
|
||||
render(<Harness toast={buildToast()} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Loading...')).toBeInTheDocument());
|
||||
expect(screen.queryByRole('switch')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AMATRIX-003: shows the empty hint when the server reports no event types', async () => {
|
||||
matrixRespondsWith({ event_types: [], implemented_combos: {}, preferences: {} });
|
||||
render(<Harness toast={buildToast()} />);
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'No notification channels are configured. Ask an admin to set up email or webhook notifications.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AMATRIX-004: renders only channels that are active and implemented somewhere', async () => {
|
||||
matrixRespondsWith(MATRIX);
|
||||
render(<Harness toast={buildToast()} />);
|
||||
|
||||
await screen.findByText('New version available');
|
||||
expect(screen.getByText('In-App')).toBeInTheDocument();
|
||||
expect(screen.getByText('Email')).toBeInTheDocument();
|
||||
// webhook is inactive, ntfy is active but not implemented for any event
|
||||
expect(screen.queryByText('Webhook')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Ntfy')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AMATRIX-005: falls back to the raw event id when no label key exists', async () => {
|
||||
matrixRespondsWith(MATRIX);
|
||||
render(<Harness toast={buildToast()} />);
|
||||
|
||||
expect(await screen.findByText('trip_reminder')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AMATRIX-006: reflects stored preferences and defaults missing ones to on', async () => {
|
||||
matrixRespondsWith(MATRIX);
|
||||
render(<Harness toast={buildToast()} />);
|
||||
|
||||
const versionInapp = await screen.findByRole('switch', { name: 'version_available inapp' });
|
||||
expect(versionInapp).toHaveAttribute('aria-checked', 'true');
|
||||
expect(screen.getByRole('switch', { name: 'version_available email' })).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'false',
|
||||
);
|
||||
// trip_reminder has no stored preference at all
|
||||
expect(screen.getByRole('switch', { name: 'trip_reminder inapp' })).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
it('FE-MOB-AMATRIX-007: renders a dash for combos the backend does not implement', async () => {
|
||||
matrixRespondsWith(MATRIX);
|
||||
render(<Harness toast={buildToast()} />);
|
||||
|
||||
await screen.findByText('trip_reminder');
|
||||
expect(screen.queryByRole('switch', { name: 'trip_reminder email' })).not.toBeInTheDocument();
|
||||
expect(screen.getByText('—')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AMATRIX-008: toggling a cell persists the whole preference map', async () => {
|
||||
matrixRespondsWith(MATRIX);
|
||||
let body: Record<string, Record<string, boolean>> | undefined;
|
||||
server.use(
|
||||
http.put('/api/admin/notification-preferences', async ({ request }) => {
|
||||
body = (await request.json()) as Record<string, Record<string, boolean>>;
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<Harness toast={buildToast()} />);
|
||||
|
||||
await user.click(await screen.findByRole('switch', { name: 'version_available email' }));
|
||||
|
||||
await waitFor(() => expect(body).toBeDefined());
|
||||
expect(body).toEqual({ version_available: { inapp: true, email: true } });
|
||||
expect(screen.getByRole('switch', { name: 'version_available email' })).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-MOB-AMATRIX-009: a failed save reverts the toggle and reports the error', async () => {
|
||||
matrixRespondsWith(MATRIX);
|
||||
server.use(
|
||||
http.put('/api/admin/notification-preferences', () => HttpResponse.json({}, { status: 500 })),
|
||||
);
|
||||
const toast = buildToast();
|
||||
const user = userEvent.setup();
|
||||
render(<Harness toast={toast} />);
|
||||
|
||||
await user.click(await screen.findByRole('switch', { name: 'version_available inapp' }));
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Error'));
|
||||
expect(screen.getByRole('switch', { name: 'version_available inapp' })).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-MOB-AMATRIX-010: toggling an event without stored preferences seeds it from the default', async () => {
|
||||
matrixRespondsWith(MATRIX);
|
||||
let body: Record<string, Record<string, boolean>> | undefined;
|
||||
server.use(
|
||||
http.put('/api/admin/notification-preferences', async ({ request }) => {
|
||||
body = (await request.json()) as Record<string, Record<string, boolean>>;
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<Harness toast={buildToast()} />);
|
||||
|
||||
await user.click(await screen.findByRole('switch', { name: 'trip_reminder inapp' }));
|
||||
|
||||
await waitFor(() => expect(body).toBeDefined());
|
||||
expect(body).toEqual({
|
||||
version_available: { inapp: true, email: false },
|
||||
trip_reminder: { inapp: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-MOB-AMATRIX-011: a response without a channel list renders the events but no toggles', async () => {
|
||||
matrixRespondsWith({
|
||||
event_types: ['version_available'],
|
||||
implemented_combos: { version_available: ['inapp'] },
|
||||
preferences: {},
|
||||
});
|
||||
render(<Harness toast={buildToast()} />);
|
||||
|
||||
expect(await screen.findByText('New version available')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('switch')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('In-App')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,677 @@
|
||||
// FE-MOB-APKG-001 to FE-MOB-APKG-028
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { render, screen, fireEvent, waitFor, within } from '../../../helpers/render';
|
||||
import { server } from '../../../helpers/msw/server';
|
||||
import { resetAllStores } from '../../../helpers/store';
|
||||
import { ToastContainer } from '../../../../src/components/shared/Toast';
|
||||
import MAdminPackingTemplateManager from '../../../../src/mobile/screens/admin/MAdminPackingTemplateManager';
|
||||
|
||||
const tmpl1 = { id: 1, name: 'Beach Trip', item_count: 5, category_count: 2, created_by_name: 'admin' };
|
||||
const tmpl2 = { id: 2, name: 'City Break', item_count: 3, category_count: 1, created_by_name: 'admin' };
|
||||
|
||||
const cat1 = { id: 10, template_id: 1, name: 'Clothing', sort_order: 0 };
|
||||
const item1 = { id: 100, category_id: 10, name: 'T-shirt', sort_order: 0 };
|
||||
const item2 = { id: 101, category_id: 10, name: 'Shorts', sort_order: 1 };
|
||||
|
||||
function list(templates: unknown[]) {
|
||||
return http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates }));
|
||||
}
|
||||
|
||||
function detail(id: number, categories: unknown[], items: unknown[]) {
|
||||
return http.get(`/api/admin/packing-templates/${id}`, () => HttpResponse.json({ categories, items }));
|
||||
}
|
||||
|
||||
function renderManager() {
|
||||
return render(<><ToastContainer /><MAdminPackingTemplateManager /></>);
|
||||
}
|
||||
|
||||
// Template rows nest the name two levels below the row that holds the icon buttons.
|
||||
function templateRow(name: string): HTMLElement {
|
||||
return screen.getByText(name).parentElement!.parentElement as HTMLElement;
|
||||
}
|
||||
|
||||
function categoryHeader(name: string): HTMLElement {
|
||||
return screen.getByText(name).closest('div') as HTMLElement;
|
||||
}
|
||||
|
||||
function itemRow(name: string): HTMLElement {
|
||||
return screen.getByText(name).closest('div') as HTMLElement;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('MAdminPackingTemplateManager', () => {
|
||||
it('FE-MOB-APKG-001: shows the spinner while templates load, then the empty state', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', async () => {
|
||||
await new Promise(r => setTimeout(r, 60));
|
||||
return HttpResponse.json({ templates: [] });
|
||||
}),
|
||||
);
|
||||
renderManager();
|
||||
|
||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
expect(screen.getByText('Packing Templates')).toBeInTheDocument();
|
||||
expect(screen.getByText('Create reusable packing lists for your trips')).toBeInTheDocument();
|
||||
|
||||
await screen.findByText('No templates created yet');
|
||||
expect(document.querySelector('.animate-spin')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-APKG-002: renders each template with its category and item counts', async () => {
|
||||
server.use(list([tmpl1, tmpl2]));
|
||||
renderManager();
|
||||
|
||||
await screen.findByText('Beach Trip');
|
||||
expect(screen.getByText('City Break')).toBeInTheDocument();
|
||||
expect(screen.getByText('2 categories · 5 items')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 categories · 3 items')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-APKG-003: a failing list request surfaces the load error toast', async () => {
|
||||
server.use(http.get('/api/admin/packing-templates', () => HttpResponse.error()));
|
||||
renderManager();
|
||||
|
||||
await screen.findByText('Failed to load templates');
|
||||
expect(screen.getByText('No templates created yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-APKG-004: creating a template prepends it, expands it and toasts', async () => {
|
||||
const user = userEvent.setup();
|
||||
let posted: unknown = null;
|
||||
server.use(
|
||||
list([tmpl1]),
|
||||
http.post('/api/admin/packing-templates', async ({ request }) => {
|
||||
posted = await request.json();
|
||||
return HttpResponse.json({ template: { id: 99, name: 'Winter Trip' } });
|
||||
}),
|
||||
detail(99, [], []),
|
||||
);
|
||||
renderManager();
|
||||
await screen.findByText('Beach Trip');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'New Template' }));
|
||||
await user.type(screen.getByPlaceholderText('Template name (e.g. Beach Holiday)'), ' Winter Trip {Enter}');
|
||||
|
||||
await waitFor(() => expect(posted).toEqual({ name: 'Winter Trip' }));
|
||||
await screen.findByText('Template created');
|
||||
expect(screen.getByText('0 categories · 0 items')).toBeInTheDocument();
|
||||
// The new template is expanded right away, so its category editor is visible.
|
||||
expect(screen.getByRole('button', { name: 'Add category' })).toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText('Template name (e.g. Beach Holiday)')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-APKG-005: saving a blank template name is a no-op', async () => {
|
||||
const user = userEvent.setup();
|
||||
let postCalls = 0;
|
||||
server.use(
|
||||
http.post('/api/admin/packing-templates', () => {
|
||||
postCalls += 1;
|
||||
return HttpResponse.json({ template: { id: 5, name: '' } });
|
||||
}),
|
||||
);
|
||||
renderManager();
|
||||
await screen.findByText('No templates created yet');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'New Template' }));
|
||||
await user.type(screen.getByPlaceholderText('Template name (e.g. Beach Holiday)'), ' ');
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
expect(postCalls).toBe(0);
|
||||
expect(screen.getByPlaceholderText('Template name (e.g. Beach Holiday)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-APKG-006: a failing create shows the create error toast', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/admin/packing-templates', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
|
||||
);
|
||||
renderManager();
|
||||
await screen.findByText('No templates created yet');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'New Template' }));
|
||||
await user.type(screen.getByPlaceholderText('Template name (e.g. Beach Holiday)'), 'Boom{Enter}');
|
||||
|
||||
await screen.findByText('Failed to create template');
|
||||
});
|
||||
|
||||
it('FE-MOB-APKG-007: Escape and the cancel button both close the create field', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderManager();
|
||||
await screen.findByText('No templates created yet');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'New Template' }));
|
||||
await user.type(screen.getByPlaceholderText('Template name (e.g. Beach Holiday)'), 'Draft{Escape}');
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByPlaceholderText('Template name (e.g. Beach Holiday)')).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'New Template' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByPlaceholderText('Template name (e.g. Beach Holiday)')).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-MOB-APKG-008: expanding loads categories and items, collapsing hides them again', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(list([tmpl1]), detail(1, [cat1], [item1, item2]));
|
||||
renderManager();
|
||||
await screen.findByText('Beach Trip');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }));
|
||||
await screen.findByText('Clothing');
|
||||
expect(screen.getByText('T-shirt')).toBeInTheDocument();
|
||||
expect(screen.getByText('Shorts')).toBeInTheDocument();
|
||||
// The category header counts its items.
|
||||
expect(within(categoryHeader('Clothing')).getByText('2')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Collapse' }));
|
||||
await waitFor(() => expect(screen.queryByText('Clothing')).not.toBeInTheDocument());
|
||||
expect(screen.queryByText('T-shirt')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-APKG-009: a failing detail request toasts and leaves the template empty', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
list([tmpl1]),
|
||||
http.get('/api/admin/packing-templates/1', () => HttpResponse.error()),
|
||||
);
|
||||
renderManager();
|
||||
await screen.findByText('Beach Trip');
|
||||
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
|
||||
await screen.findByText('Failed to load templates');
|
||||
expect(screen.getByRole('button', { name: 'Add category' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-APKG-010: deleting a template removes it and collapses the expanded panel', async () => {
|
||||
const user = userEvent.setup();
|
||||
let deleted = false;
|
||||
server.use(
|
||||
list([tmpl1, tmpl2]),
|
||||
detail(1, [cat1], [item1]),
|
||||
http.delete('/api/admin/packing-templates/1', () => {
|
||||
deleted = true;
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
renderManager();
|
||||
await screen.findByText('Beach Trip');
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
await screen.findByText('Clothing');
|
||||
|
||||
await user.click(within(templateRow('Beach Trip')).getByRole('button', { name: 'Delete' }));
|
||||
|
||||
await waitFor(() => expect(deleted).toBe(true));
|
||||
await waitFor(() => expect(screen.queryByText('Beach Trip')).not.toBeInTheDocument());
|
||||
expect(screen.queryByText('Clothing')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('City Break')).toBeInTheDocument();
|
||||
await screen.findByText('Template deleted');
|
||||
});
|
||||
|
||||
it('FE-MOB-APKG-011: a failing template delete keeps the row and toasts', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
list([tmpl1]),
|
||||
http.delete('/api/admin/packing-templates/1', () => HttpResponse.error()),
|
||||
);
|
||||
renderManager();
|
||||
await screen.findByText('Beach Trip');
|
||||
|
||||
await user.click(within(templateRow('Beach Trip')).getByRole('button', { name: 'Delete' }));
|
||||
|
||||
await screen.findByText('Failed to delete template');
|
||||
expect(screen.getByText('Beach Trip')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-APKG-012: renaming a template persists the trimmed name', async () => {
|
||||
const user = userEvent.setup();
|
||||
let body: unknown = null;
|
||||
server.use(
|
||||
list([tmpl1]),
|
||||
http.put('/api/admin/packing-templates/1', async ({ request }) => {
|
||||
body = await request.json();
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
renderManager();
|
||||
await screen.findByText('Beach Trip');
|
||||
|
||||
await user.click(within(templateRow('Beach Trip')).getByRole('button', { name: 'Edit' }));
|
||||
const input = screen.getByDisplayValue('Beach Trip');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'Summer Packing{Enter}');
|
||||
|
||||
await waitFor(() => expect(body).toEqual({ name: 'Summer Packing' }));
|
||||
await screen.findByText('Summer Packing');
|
||||
});
|
||||
|
||||
it('FE-MOB-APKG-013: blurring an emptied template name cancels the rename', async () => {
|
||||
const user = userEvent.setup();
|
||||
let putCalls = 0;
|
||||
server.use(
|
||||
list([tmpl1]),
|
||||
http.put('/api/admin/packing-templates/1', () => {
|
||||
putCalls += 1;
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
renderManager();
|
||||
await screen.findByText('Beach Trip');
|
||||
|
||||
await user.click(within(templateRow('Beach Trip')).getByRole('button', { name: 'Edit' }));
|
||||
const input = screen.getByDisplayValue('Beach Trip');
|
||||
await user.clear(input);
|
||||
fireEvent.blur(input);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Beach Trip')).toBeInTheDocument());
|
||||
expect(putCalls).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-MOB-APKG-014: Escape cancels the rename and a failing rename toasts', async () => {
|
||||
const user = userEvent.setup();
|
||||
let putCalls = 0;
|
||||
server.use(
|
||||
list([tmpl1]),
|
||||
http.put('/api/admin/packing-templates/1', () => {
|
||||
putCalls += 1;
|
||||
return HttpResponse.error();
|
||||
}),
|
||||
);
|
||||
renderManager();
|
||||
await screen.findByText('Beach Trip');
|
||||
|
||||
await user.click(within(templateRow('Beach Trip')).getByRole('button', { name: 'Edit' }));
|
||||
await user.type(screen.getByDisplayValue('Beach Trip'), '{Escape}');
|
||||
await waitFor(() => expect(screen.queryByDisplayValue('Beach Trip')).not.toBeInTheDocument());
|
||||
expect(putCalls).toBe(0);
|
||||
|
||||
await user.click(within(templateRow('Beach Trip')).getByRole('button', { name: 'Edit' }));
|
||||
await user.type(screen.getByDisplayValue('Beach Trip'), ' 2{Enter}');
|
||||
await screen.findByText('Failed to save');
|
||||
expect(putCalls).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-APKG-015: adding a category appends it to the expanded template', async () => {
|
||||
const user = userEvent.setup();
|
||||
let body: unknown = null;
|
||||
server.use(
|
||||
list([tmpl1]),
|
||||
detail(1, [], []),
|
||||
http.post('/api/admin/packing-templates/1/categories', async ({ request }) => {
|
||||
body = await request.json();
|
||||
return HttpResponse.json({ category: { id: 20, template_id: 1, name: 'Electronics', sort_order: 1 } });
|
||||
}),
|
||||
);
|
||||
renderManager();
|
||||
await screen.findByText('Beach Trip');
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add category' }));
|
||||
await user.type(screen.getByPlaceholderText('Category name (e.g. Clothing)'), 'Electronics{Enter}');
|
||||
|
||||
await waitFor(() => expect(body).toEqual({ name: 'Electronics' }));
|
||||
await screen.findByText('Electronics');
|
||||
expect(screen.queryByPlaceholderText('Category name (e.g. Clothing)')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-APKG-016: a blank category name is ignored and Escape closes the field', async () => {
|
||||
const user = userEvent.setup();
|
||||
let postCalls = 0;
|
||||
server.use(
|
||||
list([tmpl1]),
|
||||
detail(1, [], []),
|
||||
http.post('/api/admin/packing-templates/1/categories', () => {
|
||||
postCalls += 1;
|
||||
return HttpResponse.json({ category: { id: 20, template_id: 1, name: 'x', sort_order: 1 } });
|
||||
}),
|
||||
);
|
||||
renderManager();
|
||||
await screen.findByText('Beach Trip');
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add category' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
expect(postCalls).toBe(0);
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Category name (e.g. Clothing)'), 'Draft{Escape}');
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByPlaceholderText('Category name (e.g. Clothing)')).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
// Reopening starts from an empty field and the cancel button closes it too.
|
||||
await user.click(screen.getByRole('button', { name: 'Add category' }));
|
||||
expect(screen.getByPlaceholderText('Category name (e.g. Clothing)')).toHaveValue('');
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByPlaceholderText('Category name (e.g. Clothing)')).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(postCalls).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-MOB-APKG-017: a failing category create toasts the save error', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
list([tmpl1]),
|
||||
detail(1, [], []),
|
||||
http.post('/api/admin/packing-templates/1/categories', () => HttpResponse.error()),
|
||||
);
|
||||
renderManager();
|
||||
await screen.findByText('Beach Trip');
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add category' }));
|
||||
await user.type(screen.getByPlaceholderText('Category name (e.g. Clothing)'), 'Electronics{Enter}');
|
||||
|
||||
await screen.findByText('Failed to save');
|
||||
});
|
||||
|
||||
it('FE-MOB-APKG-018: renaming a category updates its header', async () => {
|
||||
const user = userEvent.setup();
|
||||
let body: unknown = null;
|
||||
server.use(
|
||||
list([tmpl1]),
|
||||
detail(1, [cat1], []),
|
||||
http.put('/api/admin/packing-templates/1/categories/10', async ({ request }) => {
|
||||
body = await request.json();
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
renderManager();
|
||||
await screen.findByText('Beach Trip');
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
await screen.findByText('Clothing');
|
||||
|
||||
await user.click(within(categoryHeader('Clothing')).getByRole('button', { name: 'Edit' }));
|
||||
const input = screen.getByDisplayValue('Clothing');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'Shoes{Enter}');
|
||||
|
||||
await waitFor(() => expect(body).toEqual({ name: 'Shoes' }));
|
||||
await screen.findByText('Shoes');
|
||||
});
|
||||
|
||||
it('FE-MOB-APKG-019: Escape and an emptied field both cancel the category rename', async () => {
|
||||
const user = userEvent.setup();
|
||||
let putCalls = 0;
|
||||
server.use(
|
||||
list([tmpl1]),
|
||||
detail(1, [cat1], []),
|
||||
http.put('/api/admin/packing-templates/1/categories/10', () => {
|
||||
putCalls += 1;
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
renderManager();
|
||||
await screen.findByText('Beach Trip');
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
await screen.findByText('Clothing');
|
||||
|
||||
await user.click(within(categoryHeader('Clothing')).getByRole('button', { name: 'Edit' }));
|
||||
await user.type(screen.getByDisplayValue('Clothing'), '{Escape}');
|
||||
await waitFor(() => expect(screen.queryByDisplayValue('Clothing')).not.toBeInTheDocument());
|
||||
|
||||
await user.click(within(categoryHeader('Clothing')).getByRole('button', { name: 'Edit' }));
|
||||
const input = screen.getByDisplayValue('Clothing');
|
||||
await user.clear(input);
|
||||
fireEvent.blur(input);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Clothing')).toBeInTheDocument());
|
||||
expect(putCalls).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-MOB-APKG-029: a failing category rename toasts and keeps the old name', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
list([tmpl1]),
|
||||
detail(1, [cat1], []),
|
||||
http.put('/api/admin/packing-templates/1/categories/10', () => HttpResponse.error()),
|
||||
);
|
||||
renderManager();
|
||||
await screen.findByText('Beach Trip');
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
await screen.findByText('Clothing');
|
||||
|
||||
await user.click(within(categoryHeader('Clothing')).getByRole('button', { name: 'Edit' }));
|
||||
await user.type(screen.getByDisplayValue('Clothing'), ' XL{Enter}');
|
||||
|
||||
await screen.findByText('Failed to save');
|
||||
expect(screen.getByDisplayValue('Clothing XL')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-APKG-020: deleting a category drops its items too', async () => {
|
||||
const user = userEvent.setup();
|
||||
let deleted = false;
|
||||
server.use(
|
||||
list([tmpl1]),
|
||||
detail(1, [cat1], [item1, item2]),
|
||||
http.delete('/api/admin/packing-templates/1/categories/10', () => {
|
||||
deleted = true;
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
renderManager();
|
||||
await screen.findByText('Beach Trip');
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
await screen.findByText('Clothing');
|
||||
|
||||
await user.click(within(categoryHeader('Clothing')).getByRole('button', { name: 'Delete' }));
|
||||
|
||||
await waitFor(() => expect(deleted).toBe(true));
|
||||
await waitFor(() => expect(screen.queryByText('Clothing')).not.toBeInTheDocument());
|
||||
expect(screen.queryByText('T-shirt')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Shorts')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-APKG-021: a failing category delete toasts and keeps the category', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
list([tmpl1]),
|
||||
detail(1, [cat1], [item1]),
|
||||
http.delete('/api/admin/packing-templates/1/categories/10', () => HttpResponse.error()),
|
||||
);
|
||||
renderManager();
|
||||
await screen.findByText('Beach Trip');
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
await screen.findByText('Clothing');
|
||||
|
||||
await user.click(within(categoryHeader('Clothing')).getByRole('button', { name: 'Delete' }));
|
||||
|
||||
await screen.findByText('Failed to delete template');
|
||||
expect(screen.getByText('Clothing')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-APKG-022: adding an item posts to the category and clears the field', async () => {
|
||||
const user = userEvent.setup();
|
||||
let body: unknown = null;
|
||||
server.use(
|
||||
list([tmpl1]),
|
||||
detail(1, [cat1], []),
|
||||
http.post('/api/admin/packing-templates/1/categories/10/items', async ({ request }) => {
|
||||
body = await request.json();
|
||||
return HttpResponse.json({ item: { id: 102, category_id: 10, name: 'Sandals', sort_order: 2 } });
|
||||
}),
|
||||
);
|
||||
renderManager();
|
||||
await screen.findByText('Beach Trip');
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
await screen.findByText('Clothing');
|
||||
|
||||
await user.click(within(categoryHeader('Clothing')).getByRole('button', { name: 'Item name' }));
|
||||
await user.type(screen.getByPlaceholderText('Item name'), 'Sandals{Enter}');
|
||||
|
||||
await waitFor(() => expect(body).toEqual({ name: 'Sandals' }));
|
||||
await screen.findByText('Sandals');
|
||||
expect(screen.getByPlaceholderText('Item name')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('FE-MOB-APKG-023: the add-item confirm button is disabled while the field is empty', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
list([tmpl1]),
|
||||
detail(1, [cat1], []),
|
||||
http.post('/api/admin/packing-templates/1/categories/10/items', () =>
|
||||
HttpResponse.json({ item: { id: 103, category_id: 10, name: 'Hat', sort_order: 3 } }),
|
||||
),
|
||||
);
|
||||
renderManager();
|
||||
await screen.findByText('Beach Trip');
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
await screen.findByText('Clothing');
|
||||
|
||||
await user.click(within(categoryHeader('Clothing')).getByRole('button', { name: 'Item name' }));
|
||||
const addRow = screen.getByPlaceholderText('Item name').closest('div') as HTMLElement;
|
||||
const confirm = within(addRow).getByRole('button', { name: 'Item name' });
|
||||
expect(confirm).toBeDisabled();
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Item name'), 'Hat');
|
||||
expect(confirm).toBeEnabled();
|
||||
await user.click(confirm);
|
||||
await screen.findByText('Hat');
|
||||
});
|
||||
|
||||
it('FE-MOB-APKG-024: Escape and cancel close the add-item field without posting', async () => {
|
||||
const user = userEvent.setup();
|
||||
let postCalls = 0;
|
||||
server.use(
|
||||
list([tmpl1]),
|
||||
detail(1, [cat1], []),
|
||||
http.post('/api/admin/packing-templates/1/categories/10/items', () => {
|
||||
postCalls += 1;
|
||||
return HttpResponse.json({ item: { id: 104, category_id: 10, name: 'x', sort_order: 4 } });
|
||||
}),
|
||||
);
|
||||
renderManager();
|
||||
await screen.findByText('Beach Trip');
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
await screen.findByText('Clothing');
|
||||
|
||||
const plus = within(categoryHeader('Clothing')).getByRole('button', { name: 'Item name' });
|
||||
await user.click(plus);
|
||||
await user.type(screen.getByPlaceholderText('Item name'), 'Draft{Escape}');
|
||||
await waitFor(() => expect(screen.queryByPlaceholderText('Item name')).not.toBeInTheDocument());
|
||||
|
||||
await user.click(plus);
|
||||
await user.type(screen.getByPlaceholderText('Item name'), 'Draft');
|
||||
const addRow = screen.getByPlaceholderText('Item name').closest('div') as HTMLElement;
|
||||
await user.click(within(addRow).getByRole('button', { name: 'Cancel' }));
|
||||
await waitFor(() => expect(screen.queryByPlaceholderText('Item name')).not.toBeInTheDocument());
|
||||
|
||||
// The plus button toggles the field shut when pressed a second time.
|
||||
await user.click(plus);
|
||||
expect(screen.getByPlaceholderText('Item name')).toBeInTheDocument();
|
||||
await user.click(plus);
|
||||
await waitFor(() => expect(screen.queryByPlaceholderText('Item name')).not.toBeInTheDocument());
|
||||
expect(postCalls).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-MOB-APKG-025: a failing item create toasts the save error', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
list([tmpl1]),
|
||||
detail(1, [cat1], []),
|
||||
http.post('/api/admin/packing-templates/1/categories/10/items', () => HttpResponse.error()),
|
||||
);
|
||||
renderManager();
|
||||
await screen.findByText('Beach Trip');
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
await screen.findByText('Clothing');
|
||||
|
||||
await user.click(within(categoryHeader('Clothing')).getByRole('button', { name: 'Item name' }));
|
||||
await user.type(screen.getByPlaceholderText('Item name'), 'Sandals{Enter}');
|
||||
|
||||
await screen.findByText('Failed to save');
|
||||
});
|
||||
|
||||
it('FE-MOB-APKG-026: renaming an item persists via the save button', async () => {
|
||||
const user = userEvent.setup();
|
||||
let body: unknown = null;
|
||||
server.use(
|
||||
list([tmpl1]),
|
||||
detail(1, [cat1], [item1]),
|
||||
http.put('/api/admin/packing-templates/1/items/100', async ({ request }) => {
|
||||
body = await request.json();
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
renderManager();
|
||||
await screen.findByText('Beach Trip');
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
await screen.findByText('T-shirt');
|
||||
|
||||
await user.click(within(itemRow('T-shirt')).getByRole('button', { name: 'Edit' }));
|
||||
const input = screen.getByDisplayValue('T-shirt');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'Tank Top');
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => expect(body).toEqual({ name: 'Tank Top' }));
|
||||
await screen.findByText('Tank Top');
|
||||
});
|
||||
|
||||
it('FE-MOB-APKG-027: item rename cancels on Escape, on cancel and on an empty name', async () => {
|
||||
const user = userEvent.setup();
|
||||
let putCalls = 0;
|
||||
server.use(
|
||||
list([tmpl1]),
|
||||
detail(1, [cat1], [item1]),
|
||||
http.put('/api/admin/packing-templates/1/items/100', () => {
|
||||
putCalls += 1;
|
||||
return HttpResponse.error();
|
||||
}),
|
||||
);
|
||||
renderManager();
|
||||
await screen.findByText('Beach Trip');
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
await screen.findByText('T-shirt');
|
||||
|
||||
await user.click(within(itemRow('T-shirt')).getByRole('button', { name: 'Edit' }));
|
||||
await user.type(screen.getByDisplayValue('T-shirt'), '{Escape}');
|
||||
await waitFor(() => expect(screen.queryByDisplayValue('T-shirt')).not.toBeInTheDocument());
|
||||
|
||||
await user.click(within(itemRow('T-shirt')).getByRole('button', { name: 'Edit' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
await waitFor(() => expect(screen.queryByDisplayValue('T-shirt')).not.toBeInTheDocument());
|
||||
|
||||
await user.click(within(itemRow('T-shirt')).getByRole('button', { name: 'Edit' }));
|
||||
const input = screen.getByDisplayValue('T-shirt');
|
||||
await user.clear(input);
|
||||
await user.type(input, '{Enter}');
|
||||
await waitFor(() => expect(screen.getByText('T-shirt')).toBeInTheDocument());
|
||||
expect(putCalls).toBe(0);
|
||||
|
||||
// A rejected rename keeps the old name and toasts.
|
||||
await user.click(within(itemRow('T-shirt')).getByRole('button', { name: 'Edit' }));
|
||||
await user.type(screen.getByDisplayValue('T-shirt'), ' XL{Enter}');
|
||||
await screen.findByText('Failed to save');
|
||||
expect(putCalls).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-APKG-028: deleting an item removes only that item, failures toast', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
list([tmpl1]),
|
||||
detail(1, [cat1], [item1, item2]),
|
||||
http.delete('/api/admin/packing-templates/1/items/100', () => HttpResponse.json({ success: true })),
|
||||
http.delete('/api/admin/packing-templates/1/items/101', () => HttpResponse.error()),
|
||||
);
|
||||
renderManager();
|
||||
await screen.findByText('Beach Trip');
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
await screen.findByText('T-shirt');
|
||||
|
||||
await user.click(within(itemRow('T-shirt')).getByRole('button', { name: 'Delete' }));
|
||||
await waitFor(() => expect(screen.queryByText('T-shirt')).not.toBeInTheDocument());
|
||||
expect(screen.getByText('Shorts')).toBeInTheDocument();
|
||||
|
||||
await user.click(within(itemRow('Shorts')).getByRole('button', { name: 'Delete' }));
|
||||
await screen.findByText('Failed to delete template');
|
||||
expect(screen.getByText('Shorts')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
// FE-MOB-APERM-001 to FE-MOB-APERM-011
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { render, screen, waitFor } from '../../../helpers/render';
|
||||
import { server } from '../../../helpers/msw/server';
|
||||
import { resetAllStores } from '../../../helpers/store';
|
||||
import { usePermissionsStore } from '../../../../src/store/permissionsStore';
|
||||
import { ToastContainer } from '../../../../src/components/shared/Toast';
|
||||
import MAdminPermissionsPanel from '../../../../src/mobile/screens/admin/MAdminPermissionsPanel';
|
||||
|
||||
const ALLOWED = ['admin', 'trip_owner', 'trip_member', 'everybody'];
|
||||
|
||||
function buildPermission(key: string, level = 'trip_member', defaultLevel = 'trip_member') {
|
||||
return { key, level, defaultLevel, allowedLevels: [...ALLOWED] };
|
||||
}
|
||||
|
||||
const ALL_KEYS = [
|
||||
'trip_create', 'trip_edit', 'trip_delete', 'trip_archive', 'trip_cover_upload',
|
||||
'member_manage',
|
||||
'file_upload', 'file_edit', 'file_delete',
|
||||
'place_edit', 'day_edit', 'reservation_edit',
|
||||
'budget_edit', 'packing_edit', 'collab_edit', 'share_manage',
|
||||
];
|
||||
|
||||
const SAMPLE_PERMISSIONS = ALL_KEYS.map((k) => buildPermission(k));
|
||||
|
||||
function renderPanel() {
|
||||
return render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<MAdminPermissionsPanel />
|
||||
</>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
server.use(
|
||||
http.get('/api/admin/permissions', () => HttpResponse.json({ permissions: SAMPLE_PERMISSIONS })),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('MAdminPermissionsPanel', () => {
|
||||
it('FE-MOB-APERM-001: renders only the spinner while loading', () => {
|
||||
server.use(
|
||||
http.get('/api/admin/permissions', async () => {
|
||||
await new Promise(() => {});
|
||||
return HttpResponse.json({ permissions: [] });
|
||||
}),
|
||||
);
|
||||
renderPanel();
|
||||
|
||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Permission Settings')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-APERM-002: renders every category and its actions after load', async () => {
|
||||
renderPanel();
|
||||
|
||||
await screen.findByText('Permission Settings');
|
||||
expect(screen.getByText('Control who can perform actions across the application')).toBeInTheDocument();
|
||||
expect(screen.getByText('Trip Management')).toBeInTheDocument();
|
||||
expect(screen.getByText('Member Management')).toBeInTheDocument();
|
||||
expect(screen.getByText('Files')).toBeInTheDocument();
|
||||
expect(screen.getByText('Content & Schedule')).toBeInTheDocument();
|
||||
expect(screen.getByText('Budget, Packing & Collaboration')).toBeInTheDocument();
|
||||
expect(screen.getByText('Create trips')).toBeInTheDocument();
|
||||
expect(screen.getByText('Who can create new trips')).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('combobox')).toHaveLength(ALL_KEYS.length);
|
||||
});
|
||||
|
||||
it('FE-MOB-APERM-003: a select reflects the stored level and offers every allowed level', async () => {
|
||||
renderPanel();
|
||||
|
||||
await screen.findByText('Permission Settings');
|
||||
const select = screen.getByRole('combobox', { name: 'Create trips' }) as HTMLSelectElement;
|
||||
expect(select.value).toBe('trip_member');
|
||||
expect(Array.from(select.options).map((o) => o.textContent)).toEqual([
|
||||
'Admin only', 'Trip owner', 'Trip members', 'Everyone',
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-MOB-APERM-004: keys missing from the payload are skipped', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/permissions', () =>
|
||||
HttpResponse.json({ permissions: [buildPermission('trip_create')] }),
|
||||
),
|
||||
);
|
||||
renderPanel();
|
||||
|
||||
await screen.findByText('Permission Settings');
|
||||
expect(screen.getByText('Create trips')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Delete trips')).not.toBeInTheDocument();
|
||||
expect(screen.getAllByRole('combobox')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-APERM-005: the customized badge shows only where level differs from the default', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/permissions', () =>
|
||||
HttpResponse.json({
|
||||
permissions: [
|
||||
buildPermission('trip_create', 'admin', 'trip_member'),
|
||||
buildPermission('trip_edit', 'trip_member', 'trip_member'),
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
renderPanel();
|
||||
|
||||
await screen.findByText('Permission Settings');
|
||||
expect(screen.getAllByText('customized')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-APERM-006: Save stays disabled until a select changes', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
|
||||
await screen.findByText('Permission Settings');
|
||||
const save = screen.getByRole('button', { name: /Save/ });
|
||||
expect(save).toBeDisabled();
|
||||
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Create trips' }), 'admin');
|
||||
|
||||
expect(save).not.toBeDisabled();
|
||||
expect(screen.getAllByText('customized')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-APERM-007: Reset puts every value back to its default and marks the form dirty', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/permissions', () =>
|
||||
HttpResponse.json({
|
||||
permissions: [
|
||||
buildPermission('trip_create', 'admin', 'trip_member'),
|
||||
buildPermission('trip_edit', 'everybody', 'trip_owner'),
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
renderPanel();
|
||||
|
||||
await screen.findByText('Permission Settings');
|
||||
expect(screen.getAllByText('customized')).toHaveLength(2);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Reset to defaults' }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('customized')).not.toBeInTheDocument());
|
||||
expect((screen.getByRole('combobox', { name: 'Create trips' }) as HTMLSelectElement).value).toBe('trip_member');
|
||||
expect((screen.getByRole('combobox', { name: 'Edit trip details' }) as HTMLSelectElement).value).toBe('trip_owner');
|
||||
expect(screen.getByRole('button', { name: /Save/ })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-MOB-APERM-008: saving PUTs the current values and pushes them into the store', async () => {
|
||||
const user = userEvent.setup();
|
||||
let sent: Record<string, unknown> = {};
|
||||
server.use(
|
||||
http.put('/api/admin/permissions', async ({ request }) => {
|
||||
sent = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({ permissions: { trip_create: 'admin' } });
|
||||
}),
|
||||
);
|
||||
renderPanel();
|
||||
|
||||
await screen.findByText('Permission Settings');
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Create trips' }), 'admin');
|
||||
await user.click(screen.getByRole('button', { name: /Save/ }));
|
||||
|
||||
await screen.findByText('Permission settings saved');
|
||||
expect((sent.permissions as Record<string, string>).trip_create).toBe('admin');
|
||||
expect(usePermissionsStore.getState().permissions.trip_create).toBe('admin');
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /Save/ })).toBeDisabled());
|
||||
});
|
||||
|
||||
it('FE-MOB-APERM-009: a response without permissions leaves the store untouched', async () => {
|
||||
const user = userEvent.setup();
|
||||
const before = usePermissionsStore.getState().permissions;
|
||||
server.use(http.put('/api/admin/permissions', () => HttpResponse.json({ ok: true })));
|
||||
renderPanel();
|
||||
|
||||
await screen.findByText('Permission Settings');
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Create trips' }), 'admin');
|
||||
await user.click(screen.getByRole('button', { name: /Save/ }));
|
||||
|
||||
await screen.findByText('Permission settings saved');
|
||||
expect(usePermissionsStore.getState().permissions).toBe(before);
|
||||
});
|
||||
|
||||
it('FE-MOB-APERM-010: a failing save toasts and keeps the form dirty', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.put('/api/admin/permissions', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
|
||||
);
|
||||
renderPanel();
|
||||
|
||||
await screen.findByText('Permission Settings');
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Create trips' }), 'admin');
|
||||
const save = screen.getByRole('button', { name: /Save/ });
|
||||
await user.click(save);
|
||||
|
||||
await screen.findByText('Error');
|
||||
expect(save).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-MOB-APERM-011: a failing load toasts and renders no categories', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/permissions', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
|
||||
);
|
||||
renderPanel();
|
||||
|
||||
await screen.findByText('Error');
|
||||
expect(screen.getByText('Permission Settings')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,422 @@
|
||||
// FE-MOB-ASET-001 to FE-MOB-ASET-023
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { render, screen, waitFor } from '../../../helpers/render';
|
||||
import { server } from '../../../helpers/msw/server';
|
||||
import { resetAllStores } from '../../../helpers/store';
|
||||
import { buildAdminHook, type AdminHook } from '../../../helpers/mobileAdmin';
|
||||
import { useTranslation } from '../../../../src/i18n';
|
||||
import MAdminSettingsSection from '../../../../src/mobile/screens/admin/MAdminSettingsSection';
|
||||
|
||||
function Harness({ admin }: { admin: AdminHook }) {
|
||||
const { t } = useTranslation();
|
||||
return <MAdminSettingsSection admin={admin} t={t} />;
|
||||
}
|
||||
|
||||
type Spy = ReturnType<typeof vi.fn>;
|
||||
type Spies = Record<string, Spy> & { toast: Record<string, Spy> };
|
||||
|
||||
function renderSettings(overrides: Record<string, unknown> = {}) {
|
||||
const admin = buildAdminHook(overrides);
|
||||
render(<Harness admin={admin} />);
|
||||
return admin as unknown as Spies;
|
||||
}
|
||||
|
||||
function toggle(label: string): HTMLElement {
|
||||
return screen.getByRole('switch', { name: label });
|
||||
}
|
||||
|
||||
/** Client ID / Client Secret are the only inputs of their type without a placeholder. */
|
||||
function unlabelledInput(type: 'text' | 'password'): HTMLInputElement {
|
||||
const inputs = Array.from(document.querySelectorAll<HTMLInputElement>(`input[type="${type}"]`));
|
||||
const match = inputs.find((i) => !i.placeholder);
|
||||
if (!match) throw new Error(`no ${type} input without a placeholder`);
|
||||
return match;
|
||||
}
|
||||
|
||||
/**
|
||||
* setOidcConfig receives an updater that reads e.target.value lazily, so the
|
||||
* spy fixture can't be replayed after the event — this harness holds the config
|
||||
* in real state instead.
|
||||
*/
|
||||
function OidcHarness() {
|
||||
const { t } = useTranslation();
|
||||
const [oidcConfig, setOidcConfig] = React.useState({
|
||||
issuer: '',
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
client_secret_set: false,
|
||||
display_name: '',
|
||||
discovery_url: '',
|
||||
});
|
||||
const admin = buildAdminHook({ oidcConfig, setOidcConfig });
|
||||
return <MAdminSettingsSection admin={admin} t={t} />;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('MAdminSettingsSection', () => {
|
||||
it('FE-MOB-ASET-001: renders every card of the settings tab', () => {
|
||||
renderSettings();
|
||||
|
||||
expect(screen.getByText('Authentication Methods')).toBeInTheDocument();
|
||||
expect(screen.getByText('Passkey login')).toBeInTheDocument();
|
||||
expect(screen.getByText('Require two-factor authentication (2FA)')).toBeInTheDocument();
|
||||
// Card head and field label share the copy
|
||||
expect(screen.getAllByText('Allowed File Types')).toHaveLength(2);
|
||||
expect(screen.getByText('API Keys')).toBeInTheDocument();
|
||||
expect(screen.getByText('Single Sign-On (OIDC)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Danger Zone')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ASET-002: the OIDC toggles only appear once OIDC is configured', () => {
|
||||
renderSettings({ oidcConfigured: false });
|
||||
|
||||
expect(screen.queryByRole('switch', { name: 'SSO Login' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('switch', { name: 'SSO Auto-Provisioning' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ASET-003: an env override disables the password toggles and shows the hint', () => {
|
||||
renderSettings({ envOverrideOidcOnly: true, oidcConfigured: true });
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Password login settings are controlled by the OIDC_ONLY environment variable and cannot be changed here.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(toggle('Password Login')).toBeDisabled();
|
||||
expect(toggle('Password Registration')).toBeDisabled();
|
||||
expect(toggle('SSO Login')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-MOB-ASET-004: the last remaining login method cannot be switched off', () => {
|
||||
renderSettings({ passwordLogin: false, oidcLogin: false, oidcConfigured: true });
|
||||
|
||||
expect(toggle('Password Login')).toBeDisabled();
|
||||
expect(toggle('SSO Login')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-MOB-ASET-005: each auth toggle reports its own key and the new value', async () => {
|
||||
const user = userEvent.setup();
|
||||
const admin = renderSettings({ oidcConfigured: true, oidcLogin: true, oidcRegistration: false });
|
||||
|
||||
await user.click(toggle('Password Login'));
|
||||
expect(admin.handleToggleAuthSetting).toHaveBeenCalledWith(
|
||||
'password_login', false, admin.setPasswordLogin,
|
||||
);
|
||||
|
||||
await user.click(toggle('Password Registration'));
|
||||
expect(admin.handleToggleAuthSetting).toHaveBeenCalledWith(
|
||||
'password_registration', false, admin.setPasswordRegistration,
|
||||
);
|
||||
|
||||
await user.click(toggle('SSO Login'));
|
||||
expect(admin.handleToggleAuthSetting).toHaveBeenCalledWith('oidc_login', false, admin.setOidcLogin);
|
||||
|
||||
await user.click(toggle('SSO Auto-Provisioning'));
|
||||
expect(admin.handleToggleAuthSetting).toHaveBeenCalledWith(
|
||||
'oidc_registration', true, admin.setOidcRegistration,
|
||||
);
|
||||
|
||||
await user.click(toggle('Enable passkey login'));
|
||||
expect(admin.handleToggleAuthSetting).toHaveBeenCalledWith('passkey_login', true, admin.setPasskeyLogin);
|
||||
});
|
||||
|
||||
it('FE-MOB-ASET-006: the require-MFA toggle calls its dedicated handler', async () => {
|
||||
const user = userEvent.setup();
|
||||
const admin = renderSettings({ requireMfa: false });
|
||||
|
||||
await user.click(toggle('Require two-factor authentication (2FA)'));
|
||||
|
||||
expect(admin.handleToggleRequireMfa).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('FE-MOB-ASET-007: the passkey warning shows only when enabled but unconfigured', () => {
|
||||
const { unmount } = render(
|
||||
<Harness admin={buildAdminHook({ passkeyLogin: true, passkeyConfigured: false })} />,
|
||||
);
|
||||
expect(
|
||||
screen.getByText(
|
||||
'No WebAuthn domain resolves for this deployment yet. Set APP_URL or the Relying Party ID below — passkeys stay hidden until then.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
render(<Harness admin={buildAdminHook({ passkeyLogin: true, passkeyConfigured: true })} />);
|
||||
expect(screen.queryByText(/No WebAuthn domain resolves/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ASET-008: the WebAuthn fields render their values and save through the hook', async () => {
|
||||
const user = userEvent.setup();
|
||||
const admin = renderSettings({
|
||||
webauthnRpId: 'trek.example.org',
|
||||
webauthnOrigins: 'https://trek.example.org',
|
||||
});
|
||||
|
||||
expect(screen.getByPlaceholderText('trek.example.org')).toHaveValue('trek.example.org');
|
||||
expect(screen.getByPlaceholderText('https://trek.example.org')).toHaveValue('https://trek.example.org');
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: 'Save' })[0]);
|
||||
expect(admin.handleSaveWebauthn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-ASET-009: editing the RP ID and origins goes through their setters', async () => {
|
||||
const user = userEvent.setup();
|
||||
const admin = renderSettings();
|
||||
|
||||
await user.type(screen.getByPlaceholderText('trek.example.org'), 'a');
|
||||
expect(admin.setWebauthnRpId).toHaveBeenCalledWith('a');
|
||||
|
||||
await user.type(screen.getByPlaceholderText('https://trek.example.org'), 'b');
|
||||
expect(admin.setWebauthnOrigins).toHaveBeenCalledWith('b');
|
||||
});
|
||||
|
||||
it('FE-MOB-ASET-010: saving the allowed file types PUTs them and reports success', async () => {
|
||||
const user = userEvent.setup();
|
||||
let sent: Record<string, unknown> = {};
|
||||
server.use(
|
||||
http.put('/api/auth/app-settings', async ({ request }) => {
|
||||
sent = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json(sent);
|
||||
}),
|
||||
);
|
||||
const admin = renderSettings({ allowedFileTypes: 'jpg,png' });
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: 'Save' })[1]);
|
||||
|
||||
await waitFor(() => expect(admin.toast.success).toHaveBeenCalledWith('File type settings saved'));
|
||||
expect(sent).toEqual({ allowed_file_types: 'jpg,png' });
|
||||
expect(admin.setSavingFileTypes).toHaveBeenNthCalledWith(1, true);
|
||||
expect(admin.setSavingFileTypes).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it('FE-MOB-ASET-011: a failing file-types save toasts the generic error', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.put('/api/auth/app-settings', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
|
||||
);
|
||||
const admin = renderSettings();
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: 'Save' })[1]);
|
||||
|
||||
await waitFor(() => expect(admin.toast.error).toHaveBeenCalledWith('Error'));
|
||||
expect(admin.setSavingFileTypes).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it('FE-MOB-ASET-012: the file-types input writes back through its setter', async () => {
|
||||
const user = userEvent.setup();
|
||||
const admin = renderSettings({ allowedFileTypes: '' });
|
||||
|
||||
await user.type(screen.getByPlaceholderText('jpg,png,pdf,doc,docx,xls,xlsx,txt,csv'), 'p');
|
||||
expect(admin.setAllowedFileTypes).toHaveBeenCalledWith('p');
|
||||
});
|
||||
|
||||
it('FE-MOB-ASET-013: the maps key validate button is disabled without a key', () => {
|
||||
renderSettings({ mapsKey: '' });
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Test' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-MOB-ASET-014: validating a maps key calls the handler for that key type', async () => {
|
||||
const user = userEvent.setup();
|
||||
const admin = renderSettings({ mapsKey: 'AIza-test' });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Test' }));
|
||||
|
||||
expect(admin.handleValidateKey).toHaveBeenCalledWith('maps');
|
||||
});
|
||||
|
||||
it('FE-MOB-ASET-015: the validation result renders as a valid or invalid line', () => {
|
||||
const { unmount } = render(
|
||||
<Harness admin={buildAdminHook({ mapsKey: 'k', validation: { maps: true } })} />,
|
||||
);
|
||||
expect(screen.getByText('Connected')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Invalid')).not.toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
render(<Harness admin={buildAdminHook({ mapsKey: 'k', validation: { maps: false } })} />);
|
||||
expect(screen.getByText('Invalid')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ASET-016: the API keys card saves through the hook', async () => {
|
||||
const user = userEvent.setup();
|
||||
const admin = renderSettings();
|
||||
|
||||
await user.type(screen.getAllByPlaceholderText('Enter key...')[0], 'm');
|
||||
expect(admin.setMapsKey).toHaveBeenCalledWith('m');
|
||||
|
||||
await user.type(screen.getAllByPlaceholderText('Enter key...')[1], 'u');
|
||||
expect(admin.setUnsplashKey).toHaveBeenCalledWith('u');
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: 'Save' })[2]);
|
||||
expect(admin.handleSaveApiKeys).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-ASET-017: the Google Places toggles persist optimistically', async () => {
|
||||
const user = userEvent.setup();
|
||||
const seen: string[] = [];
|
||||
server.use(
|
||||
http.put('/api/admin/places-photos', () => {
|
||||
seen.push('photos');
|
||||
return HttpResponse.json({ enabled: true });
|
||||
}),
|
||||
http.put('/api/admin/places-autocomplete', () => {
|
||||
seen.push('autocomplete');
|
||||
return HttpResponse.json({ enabled: true });
|
||||
}),
|
||||
http.put('/api/admin/places-details', () => {
|
||||
seen.push('details');
|
||||
return HttpResponse.json({ enabled: true });
|
||||
}),
|
||||
);
|
||||
const admin = renderSettings();
|
||||
|
||||
await user.click(toggle('Place Photos'));
|
||||
await user.click(toggle('Place Autocomplete'));
|
||||
await user.click(toggle('Place Details'));
|
||||
|
||||
await waitFor(() => expect(seen).toEqual(['photos', 'autocomplete', 'details']));
|
||||
expect(admin.setPlacesPhotosEnabledState).toHaveBeenCalledWith(true);
|
||||
expect(admin.setPlacesPhotosEnabled).toHaveBeenCalledWith(true);
|
||||
expect(admin.setPlacesAutocompleteEnabled).toHaveBeenCalledWith(true);
|
||||
expect(admin.setPlacesDetailsEnabled).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('FE-MOB-ASET-018: a rejected Places update rolls the toggle back', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.put('/api/admin/places-photos', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
|
||||
http.put('/api/admin/places-autocomplete', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
|
||||
http.put('/api/admin/places-details', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
|
||||
);
|
||||
const admin = renderSettings({
|
||||
placesPhotosEnabled: true,
|
||||
placesAutocompleteEnabled: true,
|
||||
placesDetailsEnabled: true,
|
||||
});
|
||||
|
||||
await user.click(toggle('Place Photos'));
|
||||
await user.click(toggle('Place Autocomplete'));
|
||||
await user.click(toggle('Place Details'));
|
||||
|
||||
await waitFor(() => expect(admin.setPlacesPhotosEnabledState).toHaveBeenLastCalledWith(true));
|
||||
expect(admin.setPlacesPhotosEnabled).toHaveBeenLastCalledWith(true);
|
||||
expect(admin.setPlacesAutocompleteEnabledState).toHaveBeenLastCalledWith(true);
|
||||
expect(admin.setPlacesDetailsEnabledState).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
|
||||
it('FE-MOB-ASET-019: the Open-Meteo card lists the three facts', () => {
|
||||
renderSettings();
|
||||
|
||||
expect(screen.getByText('Weather Data')).toBeInTheDocument();
|
||||
expect(screen.getByText('Free, no API key required')).toBeInTheDocument();
|
||||
expect(screen.getByText('16-day forecast')).toBeInTheDocument();
|
||||
expect(screen.getByText('10,000 requests / day')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ASET-020: the OIDC form renders its values and saves a payload without an empty secret', async () => {
|
||||
const user = userEvent.setup();
|
||||
let sent: Record<string, unknown> = {};
|
||||
server.use(
|
||||
http.put('/api/admin/oidc', async ({ request }) => {
|
||||
sent = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json(sent);
|
||||
}),
|
||||
);
|
||||
const admin = renderSettings({
|
||||
oidcConfig: {
|
||||
issuer: 'https://idp.example.org',
|
||||
client_id: 'trek',
|
||||
client_secret: '',
|
||||
client_secret_set: true,
|
||||
display_name: 'Authentik',
|
||||
discovery_url: 'https://idp.example.org/.well-known/openid-configuration',
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByDisplayValue('Authentik')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('https://idp.example.org')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('trek')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('••••••••')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: 'Save' })[3]);
|
||||
|
||||
await waitFor(() => expect(admin.toast.success).toHaveBeenCalledWith('OIDC configuration saved'));
|
||||
expect(sent).toEqual({
|
||||
issuer: 'https://idp.example.org',
|
||||
client_id: 'trek',
|
||||
display_name: 'Authentik',
|
||||
discovery_url: 'https://idp.example.org/.well-known/openid-configuration',
|
||||
});
|
||||
expect(admin.setSavingOidc).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it('FE-MOB-ASET-021: a filled secret is sent and a server error message is surfaced', async () => {
|
||||
const user = userEvent.setup();
|
||||
let sent: Record<string, unknown> = {};
|
||||
server.use(
|
||||
http.put('/api/admin/oidc', async ({ request }) => {
|
||||
sent = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({ error: 'Issuer unreachable' }, { status: 400 });
|
||||
}),
|
||||
);
|
||||
const admin = renderSettings({
|
||||
oidcConfig: {
|
||||
issuer: 'https://idp.example.org',
|
||||
client_id: 'trek',
|
||||
client_secret: 's3cr3t',
|
||||
client_secret_set: false,
|
||||
display_name: 'Authentik',
|
||||
discovery_url: '',
|
||||
},
|
||||
});
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: 'Save' })[3]);
|
||||
|
||||
await waitFor(() => expect(admin.toast.error).toHaveBeenCalledWith('Issuer unreachable'));
|
||||
expect(sent.client_secret).toBe('s3cr3t');
|
||||
});
|
||||
|
||||
it('FE-MOB-ASET-022: every OIDC input patches only its own field', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<OidcHarness />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText('z.B. Google, Authentik, Keycloak'), 'Authentik');
|
||||
await user.type(screen.getByPlaceholderText('https://accounts.google.com'), 'https://idp');
|
||||
await user.type(
|
||||
screen.getByPlaceholderText(
|
||||
'https://auth.example.com/application/o/trek/.well-known/openid-configuration',
|
||||
),
|
||||
'https://idp/disco',
|
||||
);
|
||||
await user.type(unlabelledInput('text'), 'trek');
|
||||
await user.type(unlabelledInput('password'), 's3cr3t');
|
||||
|
||||
expect(screen.getByPlaceholderText('z.B. Google, Authentik, Keycloak')).toHaveValue('Authentik');
|
||||
expect(screen.getByPlaceholderText('https://accounts.google.com')).toHaveValue('https://idp');
|
||||
expect(
|
||||
screen.getByPlaceholderText(
|
||||
'https://auth.example.com/application/o/trek/.well-known/openid-configuration',
|
||||
),
|
||||
).toHaveValue('https://idp/disco');
|
||||
expect(unlabelledInput('text')).toHaveValue('trek');
|
||||
expect(unlabelledInput('password')).toHaveValue('s3cr3t');
|
||||
});
|
||||
|
||||
it('FE-MOB-ASET-023: the danger zone opens the rotate-JWT confirm', async () => {
|
||||
const user = userEvent.setup();
|
||||
const admin = renderSettings();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Rotate' }));
|
||||
|
||||
expect(admin.setShowRotateJwtModal).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,430 @@
|
||||
// FE-MOB-ASHEET-001 to FE-MOB-ASHEET-024
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { fireEvent, render, screen, waitFor } from '../../../helpers/render';
|
||||
import { server } from '../../../helpers/msw/server';
|
||||
import { resetAllStores } from '../../../helpers/store';
|
||||
import { buildAdminHook, buildAdminUser, type AdminHook } from '../../../helpers/mobileAdmin';
|
||||
import { useTranslation } from '../../../../src/i18n';
|
||||
import MAdminSheets from '../../../../src/mobile/screens/admin/MAdminSheets';
|
||||
|
||||
function Harness({ admin }: { admin: AdminHook }) {
|
||||
const { t } = useTranslation();
|
||||
return <MAdminSheets admin={admin} t={t} />;
|
||||
}
|
||||
|
||||
type Spy = ReturnType<typeof vi.fn>;
|
||||
type Spies = Record<string, Spy> & { toast: Record<string, Spy> };
|
||||
|
||||
function renderSheets(overrides: Record<string, unknown> = {}) {
|
||||
const admin = buildAdminHook(overrides);
|
||||
render(<Harness admin={admin} />);
|
||||
return admin as unknown as Spies;
|
||||
}
|
||||
|
||||
interface UserForm {
|
||||
username: string;
|
||||
email: string;
|
||||
role: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The form inputs read e.target.value inside the state updater, so a spy setter
|
||||
* can't be inspected afterwards — React has already restored the controlled
|
||||
* value by then. These sheets therefore get real state.
|
||||
*/
|
||||
function StatefulSheets({
|
||||
initialForm,
|
||||
overrides,
|
||||
}: {
|
||||
initialForm: UserForm;
|
||||
overrides: Record<string, unknown>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = React.useState<UserForm>(initialForm);
|
||||
const admin = buildAdminHook({
|
||||
...overrides,
|
||||
createForm: form,
|
||||
setCreateForm: setForm,
|
||||
editForm: form,
|
||||
setEditForm: setForm,
|
||||
});
|
||||
return <MAdminSheets admin={admin} t={t} />;
|
||||
}
|
||||
|
||||
const ME = buildAdminUser({ id: 1, username: 'admin' });
|
||||
const ALICE = buildAdminUser({ id: 2, username: 'alice', email: 'alice@example.com', role: 'user' });
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('MAdminSheets', () => {
|
||||
it('FE-MOB-ASHEET-001: nothing is rendered while every sheet is closed', () => {
|
||||
renderSheets();
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ASHEET-002: the create sheet shows the four fields prefilled from createForm', () => {
|
||||
renderSheets({
|
||||
showCreateUser: true,
|
||||
createForm: { username: 'newbie', email: 'new@example.com', password: 'secret12', role: 'admin' },
|
||||
});
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'Create User' })).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Username')).toHaveValue('newbie');
|
||||
expect(screen.getByPlaceholderText('Email')).toHaveValue('new@example.com');
|
||||
expect(screen.getByPlaceholderText('Password')).toHaveValue('secret12');
|
||||
expect(screen.getByRole('tab', { name: 'Administrator' })).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
it('FE-MOB-ASHEET-003: each create-form input writes through to its own field', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<StatefulSheets
|
||||
initialForm={{ username: '', email: '', role: 'user', password: '' }}
|
||||
overrides={{ showCreateUser: true }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Username'), 'newbie');
|
||||
await user.type(screen.getByPlaceholderText('Email'), 'new@example.com');
|
||||
await user.type(screen.getByPlaceholderText('Password'), 'secret12');
|
||||
|
||||
expect(screen.getByPlaceholderText('Username')).toHaveValue('newbie');
|
||||
expect(screen.getByPlaceholderText('Email')).toHaveValue('new@example.com');
|
||||
expect(screen.getByPlaceholderText('Password')).toHaveValue('secret12');
|
||||
});
|
||||
|
||||
it('FE-MOB-ASHEET-003b: the password eye toggles the input type', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSheets({ showCreateUser: true });
|
||||
|
||||
const input = screen.getByPlaceholderText('Password');
|
||||
expect(input).toHaveAttribute('type', 'password');
|
||||
await user.click(screen.getByRole('button', { name: 'Show or hide' }));
|
||||
expect(screen.getByPlaceholderText('Password')).toHaveAttribute('type', 'text');
|
||||
});
|
||||
|
||||
it('FE-MOB-ASHEET-004: the role segment writes the picked role', async () => {
|
||||
const user = userEvent.setup();
|
||||
const admin = renderSheets({ showCreateUser: true });
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Administrator' }));
|
||||
const updater = admin.setCreateForm.mock.calls[0][0] as (f: Record<string, string>) => Record<string, string>;
|
||||
expect(updater({ username: 'a', email: 'b', password: 'c', role: 'user' }).role).toBe('admin');
|
||||
});
|
||||
|
||||
it('FE-MOB-ASHEET-005: create sheet footer submits and cancels', async () => {
|
||||
const user = userEvent.setup();
|
||||
const admin = renderSheets({ showCreateUser: true });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Create User' }));
|
||||
expect(admin.handleCreateUser).toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(admin.setShowCreateUser).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('FE-MOB-ASHEET-006: the edit sheet only renders its body once a user is being edited', () => {
|
||||
renderSheets({
|
||||
editingUser: ALICE,
|
||||
editForm: { username: 'alice', email: 'alice@example.com', role: 'user', password: '' },
|
||||
});
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'Edit User' })).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('alice')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('alice@example.com')).toBeInTheDocument();
|
||||
expect(screen.getByText('Reset passkeys')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ASHEET-007: the edit form writes username, email, password and role separately', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<StatefulSheets
|
||||
initialForm={{ username: 'alice', email: 'alice@example.com', role: 'user', password: '' }}
|
||||
overrides={{ editingUser: ALICE }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.type(screen.getByDisplayValue('alice'), 'x');
|
||||
await user.type(screen.getByDisplayValue('alice@example.com'), '.uk');
|
||||
await user.type(screen.getByPlaceholderText('Enter new password…'), 'newsecret');
|
||||
await user.click(screen.getByRole('tab', { name: 'Administrator' }));
|
||||
|
||||
expect(screen.getByDisplayValue('alicex')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('alice@example.com.uk')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Enter new password…')).toHaveValue('newsecret');
|
||||
expect(screen.getByRole('tab', { name: 'Administrator' })).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
it('FE-MOB-ASHEET-008: saving and cancelling the edit sheet call through', async () => {
|
||||
const user = userEvent.setup();
|
||||
const admin = renderSheets({ editingUser: ALICE });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
expect(admin.handleSaveUser).toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(admin.setEditingUser).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('FE-MOB-ASHEET-009: deleting closes the sheet first and then deletes that user', async () => {
|
||||
const user = userEvent.setup();
|
||||
const admin = renderSheets({ editingUser: ALICE, currentUser: ME });
|
||||
|
||||
const deleteBtn = screen.getByRole('button', { name: 'Delete user' });
|
||||
expect(deleteBtn).not.toBeDisabled();
|
||||
await user.click(deleteBtn);
|
||||
|
||||
expect(admin.setEditingUser).toHaveBeenCalledWith(null);
|
||||
expect(admin.handleDeleteUser).toHaveBeenCalledWith(ALICE);
|
||||
});
|
||||
|
||||
it('FE-MOB-ASHEET-010: the delete button is disabled for the signed-in admin', () => {
|
||||
renderSheets({ editingUser: ME, currentUser: ME });
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Delete user' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-MOB-ASHEET-011: resetting passkeys confirms, calls the API and reports the count', async () => {
|
||||
const user = userEvent.setup();
|
||||
let hitId = '';
|
||||
server.use(
|
||||
http.delete('/api/admin/users/:id/passkeys', ({ params }) => {
|
||||
hitId = String(params.id);
|
||||
return HttpResponse.json({ deleted: 3 });
|
||||
}),
|
||||
);
|
||||
const admin = renderSheets({ editingUser: ALICE });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Reset passkeys' }));
|
||||
const confirm = screen.getByRole('dialog', { name: 'Reset passkeys' });
|
||||
expect(confirm).toBeInTheDocument();
|
||||
expect(screen.getByText('Remove all passkeys for alice?')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: 'Reset passkeys' })[1]);
|
||||
|
||||
await waitFor(() => expect(admin.toast.success).toHaveBeenCalledWith('Removed 3 passkey(s)'));
|
||||
expect(hitId).toBe('2');
|
||||
});
|
||||
|
||||
it('FE-MOB-ASHEET-012: a failing passkey reset toasts the error and keeps the sheet open', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.delete('/api/admin/users/:id/passkeys', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
|
||||
);
|
||||
const admin = renderSheets({ editingUser: ALICE });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Reset passkeys' }));
|
||||
await user.click(screen.getAllByRole('button', { name: 'Reset passkeys' })[1]);
|
||||
|
||||
await waitFor(() => expect(admin.toast.error).toHaveBeenCalledWith('Error'));
|
||||
expect(screen.getByRole('dialog', { name: 'Reset passkeys' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ASHEET-013: the passkey confirm can be dismissed without calling the API', async () => {
|
||||
const user = userEvent.setup();
|
||||
let called = false;
|
||||
server.use(
|
||||
http.delete('/api/admin/users/:id/passkeys', () => {
|
||||
called = true;
|
||||
return HttpResponse.json({ deleted: 0 });
|
||||
}),
|
||||
);
|
||||
renderSheets({ editingUser: ALICE });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Reset passkeys' }));
|
||||
await user.click(screen.getAllByRole('button', { name: 'Cancel' })[1]);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog', { name: 'Reset passkeys' })).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-MOB-ASHEET-014: the update sheet shows the docker commands for a docker install', () => {
|
||||
renderSheets({
|
||||
showUpdateModal: true,
|
||||
updateInfo: { update_available: true, current: '3.4.0', latest: '3.5.0', is_docker: true },
|
||||
});
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'How to Update' })).toBeInTheDocument();
|
||||
expect(screen.getByText('v3.4.0 → v3.5.0')).toBeInTheDocument();
|
||||
expect(screen.getByText(/docker pull mauriceboe\/trek:latest/)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Your TREK instance runs in Docker. To update to v3.5.0, run the following commands on your server:',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: /update guide/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ASHEET-015: a non-docker install links the wiki guide instead', () => {
|
||||
renderSheets({
|
||||
showUpdateModal: true,
|
||||
updateInfo: {
|
||||
update_available: true,
|
||||
current: '3.4.0',
|
||||
latest: '3.5.0',
|
||||
is_docker: false,
|
||||
release_url: 'https://github.com/liketrek/TREK/releases/v3.5.0',
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Open the update guide' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://github.com/mauriceboe/TREK/wiki/Updating',
|
||||
);
|
||||
expect(screen.queryByText(/docker pull/)).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'View on GitHub' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://github.com/liketrek/TREK/releases/v3.5.0',
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-MOB-ASHEET-016: the update sheet closes from its footer', async () => {
|
||||
const user = userEvent.setup();
|
||||
const admin = renderSheets({
|
||||
showUpdateModal: true,
|
||||
updateInfo: { update_available: true, current: '3.4.0', latest: '3.5.0', is_docker: true },
|
||||
});
|
||||
|
||||
// Header icon button and footer button both close the sheet
|
||||
const closeButtons = screen.getAllByRole('button', { name: 'Close' });
|
||||
expect(closeButtons).toHaveLength(2);
|
||||
await user.click(closeButtons[1]);
|
||||
expect(admin.setShowUpdateModal).toHaveBeenCalledWith(false);
|
||||
|
||||
await user.click(closeButtons[0]);
|
||||
expect(admin.setShowUpdateModal).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('FE-MOB-ASHEET-017: rotating the JWT secret logs the admin out and redirects', async () => {
|
||||
const user = userEvent.setup();
|
||||
let rotated = false;
|
||||
server.use(
|
||||
http.post('/api/admin/rotate-jwt-secret', () => {
|
||||
rotated = true;
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
const admin = renderSheets({ showRotateJwtModal: true });
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'Rotate JWT Secret' })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Rotate & Log out' }));
|
||||
|
||||
await waitFor(() => expect(admin.logout).toHaveBeenCalled());
|
||||
expect(rotated).toBe(true);
|
||||
expect(admin.setRotatingJwt).toHaveBeenCalledWith(true);
|
||||
expect(admin.setShowRotateJwtModal).toHaveBeenCalledWith(false);
|
||||
expect(admin.navigate).toHaveBeenCalledWith('/login', { state: { noRedirect: true } });
|
||||
});
|
||||
|
||||
it('FE-MOB-ASHEET-018: a failing rotation toasts and leaves the session alone', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/admin/rotate-jwt-secret', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
|
||||
);
|
||||
const admin = renderSheets({ showRotateJwtModal: true });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Rotate & Log out' }));
|
||||
|
||||
await waitFor(() => expect(admin.toast.error).toHaveBeenCalledWith('Error'));
|
||||
expect(admin.logout).not.toHaveBeenCalled();
|
||||
expect(admin.setRotatingJwt).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it('FE-MOB-ASHEET-019: Escape and the header X close the rotate sheet', async () => {
|
||||
const user = userEvent.setup();
|
||||
const admin = renderSheets({ showRotateJwtModal: true });
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(admin.setShowRotateJwtModal).toHaveBeenCalledWith(false);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Close' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(admin.setShowRotateJwtModal).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('FE-MOB-ASHEET-020: Escape and the header X close the create sheet', async () => {
|
||||
const user = userEvent.setup();
|
||||
const admin = renderSheets({ showCreateUser: true });
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
await user.click(screen.getByRole('button', { name: 'Close' }));
|
||||
|
||||
expect(admin.setShowCreateUser).toHaveBeenCalledTimes(2);
|
||||
expect(admin.setShowCreateUser).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('FE-MOB-ASHEET-021: Escape and the header X close the edit sheet', async () => {
|
||||
const user = userEvent.setup();
|
||||
const admin = renderSheets({ editingUser: ALICE });
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
await user.click(screen.getByRole('button', { name: 'Close' }));
|
||||
|
||||
expect(admin.setEditingUser).toHaveBeenCalledTimes(2);
|
||||
expect(admin.setEditingUser).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('FE-MOB-ASHEET-022: Escape closes the update sheet', () => {
|
||||
const admin = renderSheets({
|
||||
showUpdateModal: true,
|
||||
updateInfo: { update_available: true, current: '3.4.0', latest: '3.5.0', is_docker: true },
|
||||
});
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
|
||||
expect(admin.setShowUpdateModal).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('FE-MOB-ASHEET-023: the passkey sheet closes from Escape and from its header X', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSheets({ editingUser: ALICE });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Reset passkeys' }));
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog', { name: 'Reset passkeys' })).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Reset passkeys' }));
|
||||
// Second dialog in the DOM is the confirm on top of the edit sheet
|
||||
await user.click(screen.getAllByRole('button', { name: 'Close' })[1]);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog', { name: 'Reset passkeys' })).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-MOB-ASHEET-024: confirming the passkey reset without an edited user does nothing', async () => {
|
||||
const user = userEvent.setup();
|
||||
let called = false;
|
||||
server.use(
|
||||
http.delete('/api/admin/users/:id/passkeys', () => {
|
||||
called = true;
|
||||
return HttpResponse.json({ deleted: 0 });
|
||||
}),
|
||||
);
|
||||
const admin = buildAdminHook({ editingUser: ALICE });
|
||||
const { rerender } = render(<Harness admin={admin} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Reset passkeys' }));
|
||||
// The edit sheet closes underneath while the confirm stays open
|
||||
rerender(<Harness admin={buildAdminHook({ editingUser: null })} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Reset passkeys' }));
|
||||
|
||||
expect(called).toBe(false);
|
||||
expect(screen.getByRole('dialog', { name: 'Reset passkeys' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
// FE-MOB-AUSERS-001 to FE-MOB-AUSERS-016
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { fireEvent, render, screen } from '../../../helpers/render';
|
||||
import { resetAllStores } from '../../../helpers/store';
|
||||
import { buildAdminHook, buildAdminUser, type AdminHook } from '../../../helpers/mobileAdmin';
|
||||
import { useTranslation } from '../../../../src/i18n';
|
||||
import MAdminUsersSection from '../../../../src/mobile/screens/admin/MAdminUsersSection';
|
||||
|
||||
// The permissions matrix self-loads and has its own suite — stub it out here.
|
||||
vi.mock('../../../../src/mobile/screens/admin/MAdminPermissionsPanel', () => ({
|
||||
default: () => <div data-testid="permissions-panel" />,
|
||||
}));
|
||||
|
||||
function Harness({ admin }: { admin: AdminHook }) {
|
||||
const { t, locale } = useTranslation();
|
||||
return <MAdminUsersSection admin={admin} t={t} locale={locale} />;
|
||||
}
|
||||
|
||||
function renderSection(overrides: Record<string, unknown> = {}) {
|
||||
const admin = buildAdminHook(overrides);
|
||||
render(<Harness admin={admin} />);
|
||||
return admin as unknown as Record<string, ReturnType<typeof vi.fn>>;
|
||||
}
|
||||
|
||||
interface InviteForm {
|
||||
max_uses: number;
|
||||
expires_in_days: number | '';
|
||||
trip_id: number | '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The invite form controls read e.target.value inside the state updater, so the
|
||||
* spy fixture can't be inspected after the fact — the sheet needs real state to
|
||||
* show what a chip or the trip select actually writes.
|
||||
*/
|
||||
function InviteFormHarness({ overrides }: { overrides: Record<string, unknown> }) {
|
||||
const { t, locale } = useTranslation();
|
||||
const [inviteForm, setInviteForm] = React.useState<InviteForm>({
|
||||
max_uses: 1,
|
||||
expires_in_days: 7,
|
||||
trip_id: '',
|
||||
});
|
||||
const admin = buildAdminHook({ showCreateInvite: true, ...overrides, inviteForm, setInviteForm });
|
||||
return <MAdminUsersSection admin={admin} t={t} locale={locale} />;
|
||||
}
|
||||
|
||||
function isActiveChip(el: HTMLElement): boolean {
|
||||
return el.className.includes('bg-m-act');
|
||||
}
|
||||
|
||||
const ME = buildAdminUser({ id: 1, username: 'admin', email: 'admin@example.com', role: 'admin' });
|
||||
const ALICE = buildAdminUser({ id: 2, username: 'alice', email: 'alice@example.com', role: 'user' });
|
||||
|
||||
function buildInvite(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 1,
|
||||
token: 'abcdefghijklmnop',
|
||||
max_uses: 3,
|
||||
used_count: 1,
|
||||
expires_at: null,
|
||||
trip_title: null,
|
||||
created_by_name: 'admin',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('MAdminUsersSection', () => {
|
||||
it('FE-MOB-AUSERS-001: lists users with role badges, the "you" marker and the count', () => {
|
||||
renderSection({ users: [ME, ALICE], currentUser: ME });
|
||||
|
||||
expect(screen.getByText('2 users')).toBeInTheDocument();
|
||||
expect(screen.getByText('admin')).toBeInTheDocument();
|
||||
expect(screen.getByText('alice@example.com')).toBeInTheDocument();
|
||||
expect(screen.getByText('Administrator')).toBeInTheDocument();
|
||||
expect(screen.getByText('User')).toBeInTheDocument();
|
||||
expect(screen.getByText('(You)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AUSERS-002: users without an avatar get an initial, users with one get the image', () => {
|
||||
renderSection({ users: [ALICE, buildAdminUser({ id: 3, username: 'bob', avatar_url: '/uploads/bob.png' })] });
|
||||
|
||||
expect(screen.getByText('A')).toBeInTheDocument();
|
||||
expect(screen.getByRole('presentation')).toHaveAttribute('src', '/uploads/bob.png');
|
||||
});
|
||||
|
||||
it('FE-MOB-AUSERS-003: the spinner replaces the list while users are loading', () => {
|
||||
renderSection({ users: [ALICE], isLoading: true });
|
||||
|
||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
expect(screen.queryByText('alice')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AUSERS-004: tapping a row opens the edit sheet for that user', async () => {
|
||||
const user = userEvent.setup();
|
||||
const admin = renderSection({ users: [ME, ALICE], currentUser: ME });
|
||||
|
||||
await user.click(screen.getByText('alice'));
|
||||
|
||||
expect(admin.handleEditUser).toHaveBeenCalledWith(ALICE);
|
||||
});
|
||||
|
||||
it('FE-MOB-AUSERS-005: the New user button opens the create sheet', async () => {
|
||||
const user = userEvent.setup();
|
||||
const admin = renderSection();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'New user' }));
|
||||
|
||||
expect(admin.setShowCreateUser).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('FE-MOB-AUSERS-006: the invite card shows its empty state without invites', () => {
|
||||
renderSection();
|
||||
|
||||
expect(screen.getByText('Invite Links')).toBeInTheDocument();
|
||||
expect(screen.getByText('No invite links created yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AUSERS-007: an active invite shows the truncated token, usage and copy action', () => {
|
||||
renderSection({ invites: [buildInvite()] });
|
||||
|
||||
expect(screen.getByText('abcdefghijkl…')).toBeInTheDocument();
|
||||
expect(screen.getByText('Active')).toBeInTheDocument();
|
||||
expect(screen.getByText(/1\/3 used/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/by admin/)).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Copy link')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AUSERS-008: unlimited uses render as ∞ and an expiry/trip line is appended', () => {
|
||||
renderSection({
|
||||
invites: [
|
||||
buildInvite({ max_uses: 0, used_count: 4, expires_at: '2099-01-02T00:00:00Z', trip_title: 'Japan' }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(screen.getByText(/4\/∞ used/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/expires 1\/2\/2099/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/adds to Japan/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AUSERS-009: expired and used-up invites lose the copy action', () => {
|
||||
renderSection({
|
||||
invites: [
|
||||
buildInvite({ id: 1, token: 'expired-token-xx', expires_at: '2020-01-01T00:00:00Z' }),
|
||||
buildInvite({ id: 2, token: 'usedup-token-xxx', max_uses: 2, used_count: 2 }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(screen.getByText('Expired')).toBeInTheDocument();
|
||||
expect(screen.getByText('Used up')).toBeInTheDocument();
|
||||
expect(screen.queryByTitle('Copy link')).not.toBeInTheDocument();
|
||||
expect(screen.getAllByTitle('Delete')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-MOB-AUSERS-010: copy and delete hit the handlers with token and id', async () => {
|
||||
const user = userEvent.setup();
|
||||
const admin = renderSection({ invites: [buildInvite({ id: 7, token: 'abcdefghijklmnop' })] });
|
||||
|
||||
await user.click(screen.getByTitle('Copy link'));
|
||||
expect(admin.copyInviteLink).toHaveBeenCalledWith('abcdefghijklmnop');
|
||||
|
||||
await user.click(screen.getByTitle('Delete'));
|
||||
expect(admin.handleDeleteInvite).toHaveBeenCalledWith(7);
|
||||
});
|
||||
|
||||
it('FE-MOB-AUSERS-011: the create-invite sheet is closed until showCreateInvite flips', async () => {
|
||||
const user = userEvent.setup();
|
||||
const admin = renderSection();
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Create Link' }));
|
||||
expect(admin.setShowCreateInvite).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('FE-MOB-AUSERS-012: the open sheet marks the active max-uses and expiry chips', () => {
|
||||
renderSection({ showCreateInvite: true, inviteForm: { max_uses: 3, expires_in_days: '', trip_id: '' } });
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'Create Link' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '3×' }).className).toContain('bg-m-act');
|
||||
expect(screen.getByRole('button', { name: '1×' }).className).not.toContain('bg-m-act');
|
||||
// Both chip rows end in ∞ — max uses (unlimited) and expiry (never)
|
||||
const [unlimitedUses, neverExpires] = screen.getAllByRole('button', { name: '∞' });
|
||||
expect(unlimitedUses.className).not.toContain('bg-m-act');
|
||||
expect(neverExpires.className).toContain('bg-m-act');
|
||||
});
|
||||
|
||||
it('FE-MOB-AUSERS-013: chips patch only their own field of the invite form', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<InviteFormHarness overrides={{}} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '5×' }));
|
||||
expect(isActiveChip(screen.getByRole('button', { name: '5×' }))).toBe(true);
|
||||
expect(isActiveChip(screen.getByRole('button', { name: '1×' }))).toBe(false);
|
||||
// The expiry row is untouched: 7d is still the active chip
|
||||
expect(isActiveChip(screen.getByRole('button', { name: '7d' }))).toBe(true);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '14d' }));
|
||||
expect(isActiveChip(screen.getByRole('button', { name: '14d' }))).toBe(true);
|
||||
expect(isActiveChip(screen.getByRole('button', { name: '7d' }))).toBe(false);
|
||||
expect(isActiveChip(screen.getByRole('button', { name: '5×' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-MOB-AUSERS-014: the trip select is hidden without trips', () => {
|
||||
renderSection({ showCreateInvite: true });
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'Create Link' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Add to trip (optional)')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AUSERS-015: the trip select stores the numeric id and maps "No trip" back to ""', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleCreateInvite = vi.fn();
|
||||
render(
|
||||
<InviteFormHarness
|
||||
overrides={{ inviteTrips: [{ id: 4, title: 'Japan 2026' }], handleCreateInvite }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const select = screen.getByRole('combobox') as HTMLSelectElement;
|
||||
expect(screen.getByRole('option', { name: 'No trip' })).toBeInTheDocument();
|
||||
expect(select.value).toBe('');
|
||||
|
||||
fireEvent.change(select, { target: { value: '4' } });
|
||||
expect((screen.getByRole('combobox') as HTMLSelectElement).value).toBe('4');
|
||||
|
||||
fireEvent.change(select, { target: { value: '' } });
|
||||
expect((screen.getByRole('combobox') as HTMLSelectElement).value).toBe('');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Create & Copy' }));
|
||||
expect(handleCreateInvite).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-AUSERS-016: cancel, close and Escape all dismiss the sheet', async () => {
|
||||
const user = userEvent.setup();
|
||||
const admin = renderSection({ showCreateInvite: true });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(admin.setShowCreateInvite).toHaveBeenCalledWith(false);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Close' }));
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(admin.setShowCreateInvite).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '../../../helpers/render';
|
||||
import { buildAtlasController, buildAtlasData, buildBucketItem, buildCountryDetail } from '../../../helpers/atlas';
|
||||
import type { AtlasController } from '../../../../src/mobile/screens/atlas/atlasController';
|
||||
import type { CountryDetail } from '../../../../src/pages/atlas/atlasModel';
|
||||
import MAtlas from '../../../../src/mobile/screens/atlas/MAtlas';
|
||||
|
||||
// FE-MOB-ATLASSCR-001 to FE-MOB-ATLASSCR-016
|
||||
|
||||
const mocks = vi.hoisted(() => ({ atlas: {} as AtlasController }));
|
||||
|
||||
vi.mock('../../../../src/pages/atlas/useAtlas', () => ({
|
||||
useAtlas: () => mocks.atlas,
|
||||
}));
|
||||
|
||||
function setAtlas(over: Record<string, unknown> = {}): AtlasController {
|
||||
mocks.atlas = buildAtlasController(over);
|
||||
return mocks.atlas;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setAtlas();
|
||||
});
|
||||
|
||||
describe('MAtlas', () => {
|
||||
it('FE-MOB-ATLASSCR-001: shows only a spinner while the atlas is loading', () => {
|
||||
setAtlas({ loading: true });
|
||||
render(<MAtlas />);
|
||||
|
||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
expect(screen.queryByText('atlas.bucketTab')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASSCR-002: renders the map container, the bucket header and the stats card', () => {
|
||||
setAtlas({
|
||||
data: buildAtlasData({ stats: { totalTrips: 4, totalPlaces: 21, totalCountries: 7, totalDays: 30, totalCities: 9 } }),
|
||||
});
|
||||
render(<MAtlas />);
|
||||
|
||||
expect(document.querySelector('.animate-spin')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'atlas.bucketTab' })).toBeInTheDocument();
|
||||
// Stats card labels come from the real translations via useTranslation.
|
||||
expect(screen.getByText('Countries')).toBeInTheDocument();
|
||||
expect(screen.getByText('7')).toBeInTheDocument();
|
||||
expect(screen.getByText('21')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASSCR-003: the header button opens the bucket-list sheet', () => {
|
||||
setAtlas({ bucketList: [buildBucketItem({ id: 1, name: 'Kyoto', country_code: 'JP' })] });
|
||||
render(<MAtlas />);
|
||||
|
||||
expect(screen.queryByText('Kyoto')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'atlas.bucketTab' }));
|
||||
|
||||
expect(screen.getByText('Kyoto')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASSCR-004: the ?search=1 hand-off opens the search overlay and clears the param', async () => {
|
||||
setAtlas({ atlas_country_options: [{ code: 'JP', label: 'Japan' }] });
|
||||
render(<MAtlas />, { initialEntries: ['/atlas?search=1'] });
|
||||
|
||||
const input = await screen.findByPlaceholderText('Search a country...');
|
||||
expect(input).toBeInTheDocument();
|
||||
|
||||
// The param is consumed once — closing and reopening is up to the user again.
|
||||
fireEvent.click(screen.getByText('Cancel'));
|
||||
await waitFor(() => expect(screen.queryByPlaceholderText('Search a country...')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASSCR-005: no search param leaves the overlay closed', () => {
|
||||
render(<MAtlas />);
|
||||
expect(screen.queryByPlaceholderText('Search a country...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASSCR-006: picking a country from the search flies the map there and closes the overlay', async () => {
|
||||
const atlas = setAtlas({
|
||||
atlas_country_options: [{ code: 'JP', label: 'Japan' }, { code: 'FR', label: 'France' }],
|
||||
});
|
||||
render(<MAtlas />, { initialEntries: ['/atlas?search=1'] });
|
||||
|
||||
const input = await screen.findByPlaceholderText('Search a country...');
|
||||
fireEvent.change(input, { target: { value: 'jap' } });
|
||||
fireEvent.click(screen.getByText('Japan'));
|
||||
|
||||
expect(atlas.select_country_from_search).toHaveBeenCalledWith('JP');
|
||||
await waitFor(() => expect(screen.queryByPlaceholderText('Search a country...')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASSCR-007: suggestions list visited countries newest first, then bucket countries', async () => {
|
||||
setAtlas({
|
||||
data: buildAtlasData({
|
||||
countries: [
|
||||
{ code: 'FR', tripCount: 1, placeCount: 1, lastVisit: '2022-01-01' },
|
||||
{ code: 'JP', tripCount: 1, placeCount: 1, lastVisit: '2025-08-01' },
|
||||
],
|
||||
}),
|
||||
bucketList: [
|
||||
buildBucketItem({ id: 1, name: 'Lima', country_code: 'PE' }),
|
||||
buildBucketItem({ id: 2, name: 'Kyoto', country_code: 'JP' }),
|
||||
buildBucketItem({ id: 3, name: 'Andes', country_code: null }),
|
||||
buildBucketItem({ id: 4, name: 'Machu Picchu', country_code: 'PER' }),
|
||||
],
|
||||
});
|
||||
render(<MAtlas />, { initialEntries: ['/atlas?search=1'] });
|
||||
|
||||
await screen.findByPlaceholderText('Search a country...');
|
||||
const rows = screen.getAllByRole('button').filter((b) => b.querySelector('img'));
|
||||
expect(rows.map((r) => r.textContent)).toEqual(['JapanVisited', 'FranceVisited', 'PeruBucket List']);
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASSCR-008: suggestions are capped at five entries', async () => {
|
||||
setAtlas({
|
||||
data: buildAtlasData({
|
||||
countries: ['FR', 'JP', 'IT', 'ES', 'DE', 'US'].map((code, i) => ({
|
||||
code, tripCount: 1, placeCount: 1, lastVisit: `202${i}-01-01`,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
render(<MAtlas />, { initialEntries: ['/atlas?search=1'] });
|
||||
|
||||
await screen.findByPlaceholderText('Search a country...');
|
||||
expect(screen.getAllByRole('button').filter((b) => b.querySelector('img'))).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASSCR-009: loading a country detail opens the detail sheet with its counts', async () => {
|
||||
setAtlas({
|
||||
selectedCountry: 'JP',
|
||||
countryDetail: buildCountryDetail({
|
||||
places: [{ id: 1 }, { id: 2 }] as unknown as CountryDetail['places'],
|
||||
trips: [{ id: 11, title: 'Kansai' }, { id: 12, title: 'Hokkaido' }],
|
||||
}),
|
||||
});
|
||||
render(<MAtlas />);
|
||||
|
||||
expect(await screen.findByText('Japan')).toBeInTheDocument();
|
||||
expect(screen.getByText('2 atlas.places · 2 atlas.trips')).toBeInTheDocument();
|
||||
expect(screen.getByText('Kansai')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASSCR-010: a trip chip navigates to the trip planner', async () => {
|
||||
const atlas = setAtlas({
|
||||
selectedCountry: 'JP',
|
||||
countryDetail: buildCountryDetail({ trips: [{ id: 11, title: 'Kansai' }] }),
|
||||
});
|
||||
render(<MAtlas />);
|
||||
|
||||
fireEvent.click(await screen.findByText('Kansai'));
|
||||
|
||||
expect(atlas.navigate).toHaveBeenCalledWith('/trips/11');
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASSCR-011: a manually marked country offers the remove action and closes the sheet', async () => {
|
||||
const atlas = setAtlas({
|
||||
selectedCountry: 'JP',
|
||||
countryDetail: buildCountryDetail({ manually_marked: true }),
|
||||
});
|
||||
render(<MAtlas />);
|
||||
|
||||
fireEvent.click(await screen.findByText('atlas.unmark'));
|
||||
|
||||
expect(atlas.handleUnmarkCountry).toHaveBeenCalledWith('JP');
|
||||
await waitFor(() => expect(screen.queryByText('atlas.unmark')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASSCR-012: a country derived from real trips has no remove action', async () => {
|
||||
setAtlas({
|
||||
selectedCountry: 'JP',
|
||||
countryDetail: buildCountryDetail({ trips: [{ id: 11, title: 'Kansai' }] }),
|
||||
});
|
||||
render(<MAtlas />);
|
||||
|
||||
await screen.findByText('Japan');
|
||||
expect(screen.queryByText('atlas.unmark')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASSCR-013: the detail sheet closes via its close button', async () => {
|
||||
setAtlas({ selectedCountry: 'JP', countryDetail: buildCountryDetail() });
|
||||
render(<MAtlas />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'common.close' }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Japan')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASSCR-014: the detail sheet also closes on Escape', async () => {
|
||||
setAtlas({ selectedCountry: 'JP', countryDetail: buildCountryDetail() });
|
||||
render(<MAtlas />);
|
||||
|
||||
await screen.findByText('Japan');
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Japan')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASSCR-015: the bucket sheet closes again from its own close button', async () => {
|
||||
setAtlas({ bucketList: [buildBucketItem({ id: 1, name: 'Kyoto', country_code: 'JP' })] });
|
||||
render(<MAtlas />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'atlas.bucketTab' }));
|
||||
expect(screen.getByText('Kyoto')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.close' }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Kyoto')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASSCR-016: duplicate and never-visited countries collapse into one suggestion each', async () => {
|
||||
setAtlas({
|
||||
data: buildAtlasData({
|
||||
countries: [
|
||||
{ code: 'FR', tripCount: 1, placeCount: 1 },
|
||||
{ code: 'FR', tripCount: 2, placeCount: 3 },
|
||||
{ code: 'JP', tripCount: 1, placeCount: 1 },
|
||||
],
|
||||
}),
|
||||
});
|
||||
render(<MAtlas />, { initialEntries: ['/atlas?search=1'] });
|
||||
|
||||
await screen.findByPlaceholderText('Search a country...');
|
||||
const rows = screen.getAllByRole('button').filter((b) => b.querySelector('img'));
|
||||
expect(rows.map((r) => r.textContent)).toEqual(['FranceVisited', 'JapanVisited']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '../../../helpers/render';
|
||||
import { buildAtlasController, buildBucketItem } from '../../../helpers/atlas';
|
||||
import MAtlasBucketSheet from '../../../../src/mobile/screens/atlas/MAtlasBucketSheet';
|
||||
|
||||
// FE-MOB-ATLASBUCK-001 to FE-MOB-ATLASBUCK-021
|
||||
|
||||
function renderSheet(over: Record<string, unknown> = {}, props: { open?: boolean; onClose?: () => void } = {}) {
|
||||
const atlas = buildAtlasController(over);
|
||||
const onClose = props.onClose ?? vi.fn(() => undefined);
|
||||
render(<MAtlasBucketSheet atlas={atlas} open={props.open ?? true} onClose={onClose} />);
|
||||
return { atlas, onClose };
|
||||
}
|
||||
|
||||
const emptyForm = { name: '', notes: '', lat: '', lng: '', target_date: '' };
|
||||
|
||||
describe('MAtlasBucketSheet', () => {
|
||||
it('FE-MOB-ATLASBUCK-001: stays unmounted while closed', () => {
|
||||
renderSheet({}, { open: false });
|
||||
expect(screen.queryByText('atlas.bucketTab')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASBUCK-002: an empty list shows the empty state and no counter', () => {
|
||||
renderSheet({ bucketList: [] });
|
||||
|
||||
expect(screen.getByText('atlas.bucketEmpty')).toBeInTheDocument();
|
||||
expect(screen.getByText('atlas.bucketEmptyHint')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASBUCK-003: items render with country, target month, coordinates and notes', () => {
|
||||
renderSheet({
|
||||
bucketList: [
|
||||
buildBucketItem({ id: 1, name: 'Kyoto', country_code: 'JP', target_date: '2027-04', lat: 35.01, lng: 135.77, notes: 'cherry blossom' }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(screen.getByText('Kyoto')).toBeInTheDocument();
|
||||
expect(screen.getByText('Japan · Apr 2027 · 35.01, 135.77 · cherry blossom')).toBeInTheDocument();
|
||||
expect(screen.getByText('1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASBUCK-004: an alpha-3 country code is resolved back to its flag and name', () => {
|
||||
renderSheet({ bucketList: [buildBucketItem({ id: 2, name: 'Porto', country_code: 'PRT' })] });
|
||||
|
||||
const flag = document.querySelector('img') as HTMLImageElement | null;
|
||||
expect(flag?.src).toContain('flagcdn.com/w40/pt.png');
|
||||
expect(screen.getByText('Portugal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASBUCK-005: an item without a country shows the star placeholder and only its name', () => {
|
||||
renderSheet({ bucketList: [buildBucketItem({ id: 3, name: 'Northern lights', country_code: null })] });
|
||||
|
||||
expect(document.querySelector('img')).toBeNull();
|
||||
expect(screen.getByText('Northern lights')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASBUCK-006: a year-only target date renders unchanged', () => {
|
||||
renderSheet({ bucketList: [buildBucketItem({ id: 4, name: 'Patagonia', country_code: null, target_date: '2030' })] });
|
||||
expect(screen.getByText('2030')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASBUCK-007: the trash button deletes the item by id', () => {
|
||||
const { atlas } = renderSheet({ bucketList: [buildBucketItem({ id: 9, name: 'Kyoto' })] });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'atlas.unmark' }));
|
||||
|
||||
expect(atlas.handleDeleteBucketItem).toHaveBeenCalledWith(9);
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASBUCK-008: the add button opens the form', () => {
|
||||
const { atlas } = renderSheet({ showBucketAdd: false });
|
||||
|
||||
fireEvent.click(screen.getByText('atlas.addPoi'));
|
||||
|
||||
expect(atlas.setShowBucketAdd).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASBUCK-009: typing without a picked place feeds the POI search field', () => {
|
||||
const { atlas } = renderSheet({ showBucketAdd: true });
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('atlas.bucketNamePlaceholder'), { target: { value: 'Kyo' } });
|
||||
|
||||
expect(atlas.setBucketSearch).toHaveBeenCalledWith('Kyo');
|
||||
expect(atlas.setBucketForm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASBUCK-010: typing after a place was picked edits the name instead', () => {
|
||||
const { atlas } = renderSheet({ showBucketAdd: true, bucketForm: { ...emptyForm, name: 'Kyoto' } });
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('atlas.bucketNamePlaceholder'), { target: { value: 'Kyoto old town' } });
|
||||
|
||||
expect(atlas.setBucketForm).toHaveBeenCalledWith({ ...emptyForm, name: 'Kyoto old town' });
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASBUCK-011: Enter searches while the name is empty and adds once it is set', () => {
|
||||
const search = renderSheet({ showBucketAdd: true });
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('atlas.bucketNamePlaceholder'), { key: 'Enter' });
|
||||
expect(search.atlas.handleBucketPoiSearch).toHaveBeenCalled();
|
||||
expect(search.atlas.handleAddBucketItem).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('atlas.bucketNamePlaceholder'), { key: 'a' });
|
||||
expect(search.atlas.handleBucketPoiSearch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASBUCK-012: the search button is disabled while a lookup is running', () => {
|
||||
const { atlas } = renderSheet({ showBucketAdd: true, bucketSearching: true });
|
||||
|
||||
const searchBtn = screen.getByRole('button', { name: 'common.search' });
|
||||
expect(searchBtn).toBeDisabled();
|
||||
|
||||
fireEvent.click(searchBtn);
|
||||
expect(atlas.handleBucketPoiSearch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASBUCK-013: picking a search result keeps notes and target month but fills name and coordinates', () => {
|
||||
const { atlas } = renderSheet({
|
||||
showBucketAdd: true,
|
||||
bucketForm: { name: '', notes: 'onsen', lat: '', lng: '', target_date: '2027-04' },
|
||||
bucketSearch: 'kyo',
|
||||
bucketSearchResults: [{ name: 'Kyoto', lat: 35.0116, lng: 135.7681, address: 'Japan' }],
|
||||
});
|
||||
|
||||
expect(screen.getByText('Japan')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('Kyoto'));
|
||||
|
||||
expect(atlas.setBucketForm).toHaveBeenCalledWith({
|
||||
name: 'Kyoto', notes: 'onsen', lat: '35.0116', lng: '135.7681', target_date: '2027-04',
|
||||
});
|
||||
expect(atlas.setBucketSearchResults).toHaveBeenCalledWith([]);
|
||||
expect(atlas.setBucketSearch).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASBUCK-014: a result without a name falls back to the typed query', () => {
|
||||
const { atlas } = renderSheet({
|
||||
showBucketAdd: true,
|
||||
bucketSearch: 'somewhere',
|
||||
bucketSearchResults: [{ address: 'Nowhere' }],
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Nowhere'));
|
||||
|
||||
expect(atlas.setBucketForm).toHaveBeenCalledWith({ ...emptyForm, name: 'somewhere', lat: '', lng: '' });
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASBUCK-015: the clear button drops the picked place but keeps the form open', () => {
|
||||
const { atlas } = renderSheet({
|
||||
showBucketAdd: true,
|
||||
bucketForm: { name: 'Kyoto', notes: 'onsen', lat: '35.01', lng: '135.77', target_date: '' },
|
||||
});
|
||||
|
||||
expect(screen.getByText('35.0100, 135.7700')).toBeInTheDocument();
|
||||
// The clear icon button sits in the name row, ahead of the form's cancel button.
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'common.cancel' })[0]);
|
||||
|
||||
expect(atlas.setBucketForm).toHaveBeenCalledWith({ name: '', notes: 'onsen', lat: '', lng: '', target_date: '' });
|
||||
expect(atlas.setShowBucketAdd).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASBUCK-016: notes feed the form and Add stays disabled until a name exists', () => {
|
||||
const { atlas } = renderSheet({ showBucketAdd: true });
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('atlas.bucketNotesPlaceholder'), { target: { value: 'diving' } });
|
||||
expect(atlas.setBucketForm).toHaveBeenCalledWith({ ...emptyForm, notes: 'diving' });
|
||||
|
||||
expect(screen.getByText('common.add')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASBUCK-017: cancelling the form resets name, search and results', () => {
|
||||
const { atlas } = renderSheet({
|
||||
showBucketAdd: true,
|
||||
bucketForm: { name: 'Kyoto', notes: 'onsen', lat: '', lng: '', target_date: '2027-04' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('common.cancel'));
|
||||
|
||||
expect(atlas.setShowBucketAdd).toHaveBeenCalledWith(false);
|
||||
expect(atlas.setBucketForm).toHaveBeenCalledWith(emptyForm);
|
||||
expect(atlas.setBucketSearch).toHaveBeenCalledWith('');
|
||||
expect(atlas.setBucketSearchResults).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASBUCK-018: closing the sheet resets the form as well', () => {
|
||||
const onClose = vi.fn(() => undefined);
|
||||
const { atlas } = renderSheet({ showBucketAdd: true }, { onClose });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.close' }));
|
||||
|
||||
expect(atlas.setShowBucketAdd).toHaveBeenCalledWith(false);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('month/year picker', () => {
|
||||
it('FE-MOB-ATLASBUCK-019: opens on the current year and writes the picked month back', () => {
|
||||
const { atlas } = renderSheet({ showBucketAdd: true });
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
fireEvent.click(screen.getByText('atlas.bucketWhen'));
|
||||
expect(screen.getByText(String(year))).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('Mar'));
|
||||
|
||||
expect(atlas.setBucketForm).toHaveBeenCalledWith({ ...emptyForm, target_date: `${year}-03` });
|
||||
expect(screen.queryByText('Mar')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASBUCK-020: the year steppers move the grid a year at a time', () => {
|
||||
renderSheet({ showBucketAdd: true, bucketForm: { ...emptyForm, target_date: '2027-04' } });
|
||||
|
||||
expect(screen.getByText('April 2027')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('April 2027'));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'mobileVacay.prevYear' }));
|
||||
expect(screen.getByText('2026')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'mobileVacay.nextYear' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'mobileVacay.nextYear' }));
|
||||
expect(screen.getByText('2028')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASBUCK-021: reset clears an already chosen target month', () => {
|
||||
const { atlas } = renderSheet({ showBucketAdd: true, bucketForm: { ...emptyForm, target_date: '2027-04' } });
|
||||
|
||||
fireEvent.click(screen.getByText('April 2027'));
|
||||
fireEvent.click(screen.getByText('common.reset'));
|
||||
|
||||
expect(atlas.setBucketForm).toHaveBeenCalledWith({ ...emptyForm, target_date: '' });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,353 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { render, screen, waitFor, fireEvent } from '../../../helpers/render';
|
||||
import { server } from '../../../helpers/msw/server';
|
||||
import { buildAtlasController, buildAtlasData, type VisitedRegions } from '../../../helpers/atlas';
|
||||
import MAtlasCountryPopup from '../../../../src/mobile/screens/atlas/MAtlasCountryPopup';
|
||||
import type { AtlasController } from '../../../../src/mobile/screens/atlas/atlasController';
|
||||
import type { AtlasData } from '../../../../src/pages/atlas/atlasModel';
|
||||
|
||||
// FE-MOB-ATLASPOP-001 to FE-MOB-ATLASPOP-025
|
||||
|
||||
type ConfirmAction = AtlasController['confirmAction'];
|
||||
|
||||
const toast = vi.fn(() => 0);
|
||||
|
||||
function renderPopup(over: Record<string, unknown> = {}) {
|
||||
const atlas = buildAtlasController(over);
|
||||
const view = render(<MAtlasCountryPopup atlas={atlas} />);
|
||||
return { atlas, view };
|
||||
}
|
||||
|
||||
function chooseAction(over: Partial<NonNullable<ConfirmAction>> = {}): ConfirmAction {
|
||||
return { type: 'choose', code: 'DE', name: 'Germany', ...over } as ConfirmAction;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
toast.mockClear();
|
||||
window.__addToast = toast;
|
||||
server.use(
|
||||
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })),
|
||||
http.post('/api/addons/atlas/region/:code/mark', () => HttpResponse.json({ success: true })),
|
||||
http.delete('/api/addons/atlas/region/:code/mark', () => HttpResponse.json({ success: true })),
|
||||
http.post('/api/addons/atlas/bucket-list', () =>
|
||||
HttpResponse.json({ item: { id: 42, name: 'Germany', country_code: 'DE', lat: null, lng: null, notes: null, target_date: '2027-05' } }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
describe('MAtlasCountryPopup', () => {
|
||||
it('FE-MOB-ATLASPOP-001: renders nothing while there is no pending action', () => {
|
||||
renderPopup({ confirmAction: null });
|
||||
expect(screen.queryByText('atlas.markVisited')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASPOP-002: a country choice shows the flag image and both option rows', () => {
|
||||
renderPopup({ confirmAction: chooseAction() });
|
||||
|
||||
const flag = document.querySelector('img') as HTMLImageElement | null;
|
||||
expect(flag?.src).toContain('flagcdn.com/w80/de.png');
|
||||
expect(screen.getByText('Germany')).toBeInTheDocument();
|
||||
expect(screen.getByText('atlas.markVisited')).toBeInTheDocument();
|
||||
expect(screen.getByText('atlas.markVisitedHint')).toBeInTheDocument();
|
||||
expect(screen.getByText('atlas.addToBucket')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASPOP-003: a non-ISO2 code falls back to the flag glyph instead of an image', () => {
|
||||
renderPopup({ confirmAction: chooseAction({ code: 'DEU', name: 'Germany' }) });
|
||||
expect(document.querySelector('img')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASPOP-004: marking a country appends it to the atlas data and closes the sheet', async () => {
|
||||
const { atlas } = renderPopup({ confirmAction: chooseAction(), data: buildAtlasData() });
|
||||
|
||||
fireEvent.click(screen.getByText('atlas.markVisited'));
|
||||
|
||||
await waitFor(() => expect(atlas.setConfirmAction).toHaveBeenCalledWith(null));
|
||||
const data = atlas.data as AtlasData;
|
||||
expect(data.countries.map((c) => c.code)).toEqual(['FR', 'DE']);
|
||||
expect(data.stats.totalCountries).toBe(2);
|
||||
expect(data.continents?.Europe).toBe(2);
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASPOP-005: marking a country already in the list leaves the data untouched', async () => {
|
||||
const { atlas } = renderPopup({ confirmAction: chooseAction({ code: 'FR', name: 'France' }) });
|
||||
const before = atlas.data;
|
||||
|
||||
fireEvent.click(screen.getByText('atlas.markVisited'));
|
||||
|
||||
await waitFor(() => expect(atlas.setConfirmAction).toHaveBeenCalledWith(null));
|
||||
expect(atlas.data).toBe(before);
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASPOP-006: a failing mark request surfaces a toast and still closes', async () => {
|
||||
server.use(
|
||||
http.post('/api/addons/atlas/country/:code/mark', () =>
|
||||
HttpResponse.json({ error: 'Country locked' }, { status: 400 }),
|
||||
),
|
||||
);
|
||||
const { atlas } = renderPopup({ confirmAction: chooseAction() });
|
||||
|
||||
fireEvent.click(screen.getByText('atlas.markVisited'));
|
||||
|
||||
await waitFor(() => expect(toast).toHaveBeenCalledWith('Country locked', 'error', undefined));
|
||||
expect(atlas.setConfirmAction).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASPOP-007: the bucket option switches the pending action to the bucket step', () => {
|
||||
const action = chooseAction();
|
||||
const { atlas } = renderPopup({ confirmAction: action });
|
||||
|
||||
fireEvent.click(screen.getByText('atlas.addToBucket'));
|
||||
|
||||
expect(atlas.setConfirmAction).toHaveBeenCalledWith({ ...action, type: 'bucket' });
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASPOP-008: a region choice shows the parent country and the region hint', () => {
|
||||
renderPopup({
|
||||
confirmAction: { type: 'choose-region', code: 'FR', name: 'Bretagne', regionCode: 'FR-BRE', countryName: 'France' },
|
||||
});
|
||||
|
||||
expect(screen.getByText('Bretagne')).toBeInTheDocument();
|
||||
expect(screen.getByText('France')).toBeInTheDocument();
|
||||
expect(screen.getByText('atlas.markRegionVisitedHint')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASPOP-009: marking a region records it and adds the country when it is new', async () => {
|
||||
const { atlas } = renderPopup({
|
||||
confirmAction: { type: 'choose-region', code: 'ES', name: 'Galicia', regionCode: 'ES-GA', countryName: 'Spain' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('atlas.markVisited'));
|
||||
|
||||
await waitFor(() => expect(atlas.setConfirmAction).toHaveBeenCalledWith(null));
|
||||
const regions = atlas.visitedRegions as VisitedRegions;
|
||||
expect(regions.ES).toEqual([{ code: 'ES-GA', name: 'Galicia', placeCount: 0, manuallyMarked: true }]);
|
||||
const data = atlas.data as AtlasData;
|
||||
expect(data.countries.map((c) => c.code)).toContain('ES');
|
||||
expect(data.stats.totalCountries).toBe(2);
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASPOP-010: marking an already-recorded region keeps the region list as is', async () => {
|
||||
const existing: VisitedRegions = { FR: [{ code: 'FR-BRE', name: 'Bretagne', placeCount: 2 }] };
|
||||
const { atlas } = renderPopup({
|
||||
confirmAction: { type: 'choose-region', code: 'FR', name: 'Bretagne', regionCode: 'FR-BRE', countryName: 'France' },
|
||||
visitedRegions: existing,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('atlas.markVisited'));
|
||||
|
||||
await waitFor(() => expect(atlas.setConfirmAction).toHaveBeenCalledWith(null));
|
||||
expect(atlas.visitedRegions).toBe(existing);
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASPOP-011: a failing region mark reports the error', async () => {
|
||||
server.use(
|
||||
http.post('/api/addons/atlas/region/:code/mark', () => HttpResponse.json({ error: 'Bad region' }, { status: 422 })),
|
||||
);
|
||||
renderPopup({
|
||||
confirmAction: { type: 'choose-region', code: 'ES', name: 'Galicia', regionCode: 'ES-GA', countryName: 'Spain' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('atlas.markVisited'));
|
||||
|
||||
await waitFor(() => expect(toast).toHaveBeenCalledWith('Bad region', 'error', undefined));
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASPOP-012: removing the last region of an unvisited-otherwise country drops the country too', async () => {
|
||||
const { atlas } = renderPopup({
|
||||
confirmAction: { type: 'unmark-region', code: 'IT', name: 'Lazio', regionCode: 'IT-62', countryName: 'Italy' },
|
||||
visitedRegions: { IT: [{ code: 'IT-62', name: 'Lazio', placeCount: 0, manuallyMarked: true }] } as VisitedRegions,
|
||||
data: buildAtlasData({
|
||||
countries: [
|
||||
{ code: 'FR', tripCount: 2, placeCount: 5 },
|
||||
{ code: 'IT', tripCount: 0, placeCount: 0 },
|
||||
],
|
||||
stats: { totalTrips: 3, totalPlaces: 10, totalCountries: 2, totalDays: 14 },
|
||||
continents: { Europe: 2 },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(screen.getByText('atlas.confirmUnmarkRegion')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('atlas.unmark'));
|
||||
|
||||
await waitFor(() => expect(atlas.setConfirmAction).toHaveBeenCalledWith(null));
|
||||
expect(atlas.visitedRegions).toEqual({});
|
||||
const data = atlas.data as AtlasData;
|
||||
expect(data.countries.map((c) => c.code)).toEqual(['FR']);
|
||||
expect(data.stats.totalCountries).toBe(1);
|
||||
expect(data.continents?.Europe).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASPOP-013: a country with real places survives removing one of its regions', async () => {
|
||||
const { atlas } = renderPopup({
|
||||
confirmAction: { type: 'unmark-region', code: 'FR', name: 'Bretagne', regionCode: 'FR-BRE', countryName: 'France' },
|
||||
visitedRegions: {
|
||||
FR: [
|
||||
{ code: 'FR-BRE', name: 'Bretagne', placeCount: 1 },
|
||||
{ code: 'FR-IDF', name: 'Ile-de-France', placeCount: 4 },
|
||||
],
|
||||
} as VisitedRegions,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('atlas.unmark'));
|
||||
|
||||
await waitFor(() => expect(atlas.setConfirmAction).toHaveBeenCalledWith(null));
|
||||
const regions = atlas.visitedRegions as VisitedRegions;
|
||||
expect(regions.FR.map((r) => r.code)).toEqual(['FR-IDF']);
|
||||
expect((atlas.data as AtlasData).countries.map((c) => c.code)).toEqual(['FR']);
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASPOP-014: a failing region removal reports the error', async () => {
|
||||
server.use(
|
||||
http.delete('/api/addons/atlas/region/:code/mark', () => HttpResponse.json({ error: 'Nope' }, { status: 500 })),
|
||||
);
|
||||
renderPopup({
|
||||
confirmAction: { type: 'unmark-region', code: 'IT', name: 'Lazio', regionCode: 'IT-62', countryName: 'Italy' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('atlas.unmark'));
|
||||
|
||||
await waitFor(() => expect(toast).toHaveBeenCalledWith('Nope', 'error', undefined));
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASPOP-015: the bucket step posts the chosen target month and prepends the item', async () => {
|
||||
let body: unknown = null;
|
||||
server.use(
|
||||
http.post('/api/addons/atlas/bucket-list', async ({ request }) => {
|
||||
body = await request.json();
|
||||
return HttpResponse.json({ item: { id: 42, name: 'Germany', country_code: 'DE', lat: null, lng: null, notes: null, target_date: '2027-05' } });
|
||||
}),
|
||||
);
|
||||
const { atlas } = renderPopup({ confirmAction: { type: 'bucket', code: 'DE', name: 'Germany' } });
|
||||
|
||||
const monthInput = document.querySelector('input[type="month"]') as HTMLInputElement;
|
||||
fireEvent.change(monthInput, { target: { value: '2027-05' } });
|
||||
expect(monthInput.value).toBe('2027-05');
|
||||
|
||||
fireEvent.click(screen.getByText('atlas.addToBucket'));
|
||||
|
||||
await waitFor(() => expect(atlas.setConfirmAction).toHaveBeenCalledWith(null));
|
||||
expect(body).toEqual({ name: 'Germany', country_code: 'DE', target_date: '2027-05' });
|
||||
expect(atlas.bucketList).toEqual([
|
||||
{ id: 42, name: 'Germany', country_code: 'DE', lat: null, lng: null, notes: null, target_date: '2027-05' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASPOP-016: back from the bucket step returns to the region choice when a region is pending', () => {
|
||||
const action = { type: 'bucket', code: 'FR', name: 'Bretagne', regionCode: 'FR-BRE' } as ConfirmAction;
|
||||
const { atlas } = renderPopup({ confirmAction: action });
|
||||
|
||||
fireEvent.click(screen.getByText('common.back'));
|
||||
|
||||
expect(atlas.setConfirmAction).toHaveBeenCalledWith({ ...action, type: 'choose-region' });
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASPOP-017: a failing bucket post reports the error', async () => {
|
||||
server.use(
|
||||
http.post('/api/addons/atlas/bucket-list', () => HttpResponse.json({ error: 'Full' }, { status: 400 })),
|
||||
);
|
||||
renderPopup({ confirmAction: { type: 'bucket', code: 'DE', name: 'Germany' } });
|
||||
|
||||
fireEvent.click(screen.getByText('atlas.addToBucket'));
|
||||
|
||||
await waitFor(() => expect(toast).toHaveBeenCalledWith('Full', 'error', undefined));
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASPOP-018: mark and unmark confirmations delegate to the shared executor', () => {
|
||||
const { atlas } = renderPopup({ confirmAction: { type: 'mark', code: 'DE', name: 'Germany' } });
|
||||
|
||||
expect(screen.getByText('atlas.confirmMark')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('atlas.markVisited'));
|
||||
expect(atlas.executeConfirmAction).toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByText('common.cancel'));
|
||||
expect(atlas.setConfirmAction).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASPOP-019: the unmark confirmation offers cancel and the destructive confirm', () => {
|
||||
const { atlas } = renderPopup({ confirmAction: { type: 'unmark', code: 'FR', name: 'France' } });
|
||||
|
||||
expect(screen.getByText('atlas.confirmUnmark')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('common.cancel'));
|
||||
expect(atlas.setConfirmAction).toHaveBeenCalledWith(null);
|
||||
|
||||
fireEvent.click(screen.getByText('atlas.unmark'));
|
||||
expect(atlas.executeConfirmAction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASPOP-020: the region removal confirmation can be cancelled', () => {
|
||||
const { atlas } = renderPopup({
|
||||
confirmAction: { type: 'unmark-region', code: 'IT', name: 'Lazio', regionCode: 'IT-62' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('common.cancel'));
|
||||
expect(atlas.setConfirmAction).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASPOP-021: the region choice can be moved to the bucket step', () => {
|
||||
const action = { type: 'choose-region', code: 'ES', name: 'Galicia', regionCode: 'ES-GA' } as ConfirmAction;
|
||||
const { atlas } = renderPopup({ confirmAction: action });
|
||||
|
||||
fireEvent.click(screen.getByText('atlas.addToBucket'));
|
||||
|
||||
expect(atlas.setConfirmAction).toHaveBeenCalledWith({ ...action, type: 'bucket' });
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASPOP-022: a region action without a region code performs no request', async () => {
|
||||
const markSpy = vi.fn();
|
||||
const deleteSpy = vi.fn();
|
||||
server.use(
|
||||
http.post('/api/addons/atlas/region/:code/mark', () => { markSpy(); return HttpResponse.json({}); }),
|
||||
http.delete('/api/addons/atlas/region/:code/mark', () => { deleteSpy(); return HttpResponse.json({}); }),
|
||||
);
|
||||
const choose = renderPopup({ confirmAction: { type: 'choose-region', code: 'ES', name: 'Galicia' } });
|
||||
fireEvent.click(screen.getByText('atlas.markVisited'));
|
||||
|
||||
const unmark = renderPopup({ confirmAction: { type: 'unmark-region', code: 'ES', name: 'Galicia' } });
|
||||
fireEvent.click(screen.getAllByText('atlas.unmark')[0]);
|
||||
|
||||
await waitFor(() => expect(choose.atlas.setVisitedRegions).not.toHaveBeenCalled());
|
||||
expect(unmark.atlas.setVisitedRegions).not.toHaveBeenCalled();
|
||||
expect(markSpy).not.toHaveBeenCalled();
|
||||
expect(deleteSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASPOP-023: a region removal keeps the country while another manual region remains', async () => {
|
||||
const { atlas } = renderPopup({
|
||||
confirmAction: { type: 'unmark-region', code: 'IT', name: 'Lazio', regionCode: 'IT-62' },
|
||||
visitedRegions: {
|
||||
IT: [
|
||||
{ code: 'IT-62', name: 'Lazio', placeCount: 0, manuallyMarked: true },
|
||||
{ code: 'IT-25', name: 'Lombardia', placeCount: 0, manuallyMarked: true },
|
||||
],
|
||||
} as VisitedRegions,
|
||||
data: buildAtlasData({
|
||||
countries: [{ code: 'IT', tripCount: 0, placeCount: 0 }],
|
||||
stats: { totalTrips: 0, totalPlaces: 0, totalCountries: 1, totalDays: 0 },
|
||||
}),
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('atlas.unmark'));
|
||||
|
||||
await waitFor(() => expect(atlas.setConfirmAction).toHaveBeenCalledWith(null));
|
||||
expect((atlas.data as AtlasData).countries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASPOP-024: mark and bucket actions cope with atlas data that never loaded', async () => {
|
||||
const { atlas } = renderPopup({ confirmAction: chooseAction(), data: null });
|
||||
|
||||
fireEvent.click(screen.getByText('atlas.markVisited'));
|
||||
|
||||
await waitFor(() => expect(atlas.setConfirmAction).toHaveBeenCalledWith(null));
|
||||
expect(atlas.data).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASPOP-025: dismissing the sheet clears the pending action', () => {
|
||||
const { atlas } = renderPopup({ confirmAction: chooseAction() });
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
|
||||
expect(atlas.setConfirmAction).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
// FE-MOB-COLADD-001 to FE-MOB-COLADD-014
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen, fireEvent, waitFor } from '../../../helpers/render'
|
||||
import { server } from '../../../helpers/msw/server'
|
||||
import type { Category } from '../../../../src/types'
|
||||
import MCollAddSheet from '../../../../src/mobile/screens/collections/MCollAddSheet'
|
||||
|
||||
const t = (key: string) => key
|
||||
|
||||
const categories = [
|
||||
{ id: 3, name: 'Restaurant', color: '#E8843D', icon: 'utensils' },
|
||||
{ id: 4, name: 'Museum', color: '#8b5cf6', icon: null },
|
||||
] as unknown as Category[]
|
||||
|
||||
type Props = React.ComponentProps<typeof MCollAddSheet>
|
||||
|
||||
function baseProps(overrides: Partial<Props> = {}): Props {
|
||||
return {
|
||||
open: true,
|
||||
collectionId: 1,
|
||||
collectionName: 'Hamburg',
|
||||
lists: [{ id: 1, name: 'Hamburg', color: '#38BDF8' }],
|
||||
categories,
|
||||
onClose: vi.fn(),
|
||||
onAdded: vi.fn(),
|
||||
t,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** Search hit shaped like the maps proxy: lat arrives as a string, website empty. */
|
||||
const hit = {
|
||||
name: 'Elbphilharmonie',
|
||||
address: 'Platz der Deutschen Einheit 1',
|
||||
lat: '53.5413',
|
||||
lng: 9.9841,
|
||||
google_place_id: 'gp-1',
|
||||
google_ftid: 'ft-1',
|
||||
osm_id: 'osm-1',
|
||||
website: '',
|
||||
phone: '+49 40 357666',
|
||||
}
|
||||
|
||||
function mockSearch(places: Record<string, unknown>[] = [hit]) {
|
||||
server.use(http.post('/api/maps/search', () => HttpResponse.json({ places })))
|
||||
}
|
||||
|
||||
type AddToast = NonNullable<typeof window.__addToast>
|
||||
let toast: Mock<AddToast>
|
||||
|
||||
/** Captures the last savePlace body so the payload mapping can be asserted. */
|
||||
function mockSave(response: Record<string, unknown> = { place: { id: 5 } }) {
|
||||
const seen: { body?: Record<string, unknown> } = {}
|
||||
server.use(
|
||||
http.post('/api/addons/collections/places', async ({ request }) => {
|
||||
seen.body = (await request.json()) as Record<string, unknown>
|
||||
return HttpResponse.json(response)
|
||||
}),
|
||||
)
|
||||
return seen
|
||||
}
|
||||
|
||||
describe('MCollAddSheet', () => {
|
||||
beforeEach(() => {
|
||||
toast = vi.fn<AddToast>(() => 1)
|
||||
window.__addToast = toast
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__addToast
|
||||
})
|
||||
|
||||
it('FE-MOB-COLADD-001: stays unmounted while closed and skips the list picker for a fixed list', () => {
|
||||
const { rerender } = render(<MCollAddSheet {...baseProps({ open: false })} />)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
|
||||
rerender(<MCollAddSheet {...baseProps()} />)
|
||||
expect(screen.getByRole('dialog', { name: 'collections.addPlace' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('COLLECTIONS.PICKLIST')).not.toBeInTheDocument()
|
||||
// Nothing typed yet → the add action is unreachable.
|
||||
expect(screen.getByRole('button', { name: /common.add/ })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-COLADD-002: searching lists the hits and picking one fills name, address and query', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockSearch()
|
||||
render(<MCollAddSheet {...baseProps()} />)
|
||||
|
||||
await user.type(screen.getByPlaceholderText('collections.addPlaceSearch'), 'elbphil')
|
||||
await user.click(screen.getByRole('button', { name: 'common.search' }))
|
||||
|
||||
const hitRow = await screen.findByText('Elbphilharmonie')
|
||||
expect(screen.getByText('Platz der Deutschen Einheit 1')).toBeInTheDocument()
|
||||
|
||||
await user.click(hitRow)
|
||||
expect(screen.getByPlaceholderText('common.name')).toHaveValue('Elbphilharmonie')
|
||||
expect(screen.getByPlaceholderText('collections.addPlaceSearch')).toHaveValue('Elbphilharmonie')
|
||||
// The result list collapses once a hit is taken, the address stays as a hint.
|
||||
expect(screen.queryByText('COMMON.SEARCH')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Platz der Deutschen Einheit 1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-COLADD-003: Enter in the search field runs the search and the panel can be dismissed', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockSearch()
|
||||
render(<MCollAddSheet {...baseProps()} />)
|
||||
|
||||
const input = screen.getByPlaceholderText('collections.addPlaceSearch')
|
||||
await user.type(input, 'elbphil{Enter}')
|
||||
expect(await screen.findByText('Elbphilharmonie')).toBeInTheDocument()
|
||||
|
||||
// The result panel has its own close affordance (there is also the sheet's).
|
||||
const closers = screen.getAllByRole('button', { name: 'common.close' })
|
||||
await user.click(closers[closers.length - 1])
|
||||
expect(screen.queryByText('Elbphilharmonie')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-COLADD-004: a failing maps search surfaces the provider error as a toast', async () => {
|
||||
const user = userEvent.setup()
|
||||
server.use(
|
||||
http.post('/api/maps/search', () =>
|
||||
HttpResponse.json({ error: 'Maps provider unavailable' }, { status: 502 }),
|
||||
),
|
||||
)
|
||||
render(<MCollAddSheet {...baseProps()} />)
|
||||
|
||||
await user.type(screen.getByPlaceholderText('collections.addPlaceSearch'), 'x')
|
||||
await user.click(screen.getByRole('button', { name: 'common.search' }))
|
||||
|
||||
await waitFor(() => expect(toast).toHaveBeenCalledWith('Maps provider unavailable', 'error', undefined))
|
||||
// The search button becomes usable again after the failure.
|
||||
expect(screen.getByRole('button', { name: 'common.search' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-COLADD-005: an empty query keeps the search button disabled', () => {
|
||||
render(<MCollAddSheet {...baseProps()} />)
|
||||
expect(screen.getByRole('button', { name: 'common.search' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-COLADD-006: saving a picked place maps the maps payload onto the save request', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockSearch()
|
||||
const seen = mockSave()
|
||||
const props = baseProps()
|
||||
render(<MCollAddSheet {...props} />)
|
||||
|
||||
await user.type(screen.getByPlaceholderText('collections.addPlaceSearch'), 'elbphil')
|
||||
await user.click(screen.getByRole('button', { name: 'common.search' }))
|
||||
await user.click(await screen.findByText('Elbphilharmonie'))
|
||||
await user.click(screen.getByRole('button', { name: /common.add/ }))
|
||||
|
||||
await waitFor(() => expect(seen.body).toBeDefined())
|
||||
expect(seen.body).toMatchObject({
|
||||
collection_id: 1,
|
||||
name: 'Elbphilharmonie',
|
||||
address: 'Platz der Deutschen Einheit 1',
|
||||
// lat arrives as a string from the proxy and has to be coerced.
|
||||
lat: 53.5413,
|
||||
lng: 9.9841,
|
||||
google_place_id: 'gp-1',
|
||||
google_ftid: 'ft-1',
|
||||
osm_id: 'osm-1',
|
||||
// An empty website string must not be stored as ''.
|
||||
website: null,
|
||||
phone: '+49 40 357666',
|
||||
status: 'idea',
|
||||
force: true,
|
||||
})
|
||||
expect(toast).toHaveBeenCalledWith('collections.addedToList', 'success', undefined)
|
||||
expect(props.onAdded).toHaveBeenCalledTimes(1)
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-COLADD-007: a hand-typed place saves without any maps provenance', async () => {
|
||||
const user = userEvent.setup()
|
||||
const seen = mockSave()
|
||||
const props = baseProps()
|
||||
render(<MCollAddSheet {...props} />)
|
||||
|
||||
await user.type(screen.getByPlaceholderText('common.name'), ' Sternschanze ')
|
||||
await user.type(screen.getByPlaceholderText('collections.descriptionPlaceholder'), 'Streetfood')
|
||||
await user.click(screen.getByRole('button', { name: /common.add/ }))
|
||||
|
||||
await waitFor(() => expect(seen.body).toBeDefined())
|
||||
expect(seen.body).toMatchObject({
|
||||
name: 'Sternschanze',
|
||||
address: null,
|
||||
lat: null,
|
||||
lng: null,
|
||||
google_place_id: null,
|
||||
description: 'Streetfood',
|
||||
category_id: null,
|
||||
})
|
||||
expect(props.onAdded).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-COLADD-008: the status chips and the category picker feed the save request', async () => {
|
||||
const user = userEvent.setup()
|
||||
const seen = mockSave()
|
||||
render(<MCollAddSheet {...baseProps()} />)
|
||||
|
||||
await user.type(screen.getByPlaceholderText('common.name'), 'Museum')
|
||||
await user.click(screen.getByRole('button', { name: /collections.status.visited/ }))
|
||||
expect(screen.getByRole('button', { name: /collections.status.visited/ })).toHaveAttribute('aria-pressed', 'true')
|
||||
|
||||
await user.click(screen.getByText('collections.noCategory'))
|
||||
await user.click(screen.getByText('Museum', { selector: 'span' }))
|
||||
await user.click(screen.getByRole('button', { name: /common.add/ }))
|
||||
|
||||
await waitFor(() => expect(seen.body).toBeDefined())
|
||||
expect(seen.body).toMatchObject({ status: 'visited', category_id: 4 })
|
||||
})
|
||||
|
||||
it('FE-MOB-COLADD-009: a duplicate reported by the server informs instead of confirming', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockSave({ duplicate: true, duplicateOf: { id: 2, name: 'Sternschanze' } })
|
||||
const props = baseProps()
|
||||
render(<MCollAddSheet {...props} />)
|
||||
|
||||
await user.type(screen.getByPlaceholderText('common.name'), 'Sternschanze')
|
||||
await user.click(screen.getByRole('button', { name: /common.add/ }))
|
||||
|
||||
await waitFor(() => expect(toast).toHaveBeenCalledWith('collections.duplicateWarning', 'info', undefined))
|
||||
expect(props.onAdded).not.toHaveBeenCalled()
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-COLADD-010: a rejected save reports the server error and leaves the sheet open', async () => {
|
||||
const user = userEvent.setup()
|
||||
server.use(
|
||||
http.post('/api/addons/collections/places', () =>
|
||||
HttpResponse.json({ error: 'List is full' }, { status: 400 }),
|
||||
),
|
||||
)
|
||||
const props = baseProps()
|
||||
render(<MCollAddSheet {...props} />)
|
||||
|
||||
await user.type(screen.getByPlaceholderText('common.name'), 'Sternschanze')
|
||||
await user.click(screen.getByRole('button', { name: /common.add/ }))
|
||||
|
||||
await waitFor(() => expect(toast).toHaveBeenCalledWith('List is full', 'error', undefined))
|
||||
expect(props.onClose).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('button', { name: /common.add/ })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-COLADD-011: opened from "All saved" with several lists the target has to be picked first', async () => {
|
||||
const user = userEvent.setup()
|
||||
const seen = mockSave()
|
||||
render(
|
||||
<MCollAddSheet
|
||||
{...baseProps({
|
||||
collectionId: null,
|
||||
lists: [
|
||||
{ id: 1, name: 'Hamburg', color: '#38BDF8' },
|
||||
{ id: 2, name: 'Kopenhagen', color: null },
|
||||
],
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('COLLECTIONS.PICKLIST')).toBeInTheDocument()
|
||||
await user.type(screen.getByPlaceholderText('common.name'), 'Tivoli')
|
||||
expect(screen.getByRole('button', { name: /common.add/ })).toBeDisabled()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Kopenhagen' }))
|
||||
expect(screen.getByRole('button', { name: 'Kopenhagen' })).toHaveAttribute('aria-pressed', 'true')
|
||||
await user.click(screen.getByRole('button', { name: /common.add/ }))
|
||||
|
||||
await waitFor(() => expect(seen.body).toBeDefined())
|
||||
expect(seen.body).toMatchObject({ collection_id: 2, name: 'Tivoli' })
|
||||
expect(toast).toHaveBeenCalledWith('collections.addedToList', 'success', undefined)
|
||||
})
|
||||
|
||||
it('FE-MOB-COLADD-012: with exactly one owned list that list is preselected', async () => {
|
||||
const user = userEvent.setup()
|
||||
const seen = mockSave()
|
||||
render(<MCollAddSheet {...baseProps({ collectionId: null })} />)
|
||||
|
||||
await user.type(screen.getByPlaceholderText('common.name'), 'Alster')
|
||||
await user.click(screen.getByRole('button', { name: /common.add/ }))
|
||||
|
||||
await waitFor(() => expect(seen.body).toBeDefined())
|
||||
expect(seen.body).toMatchObject({ collection_id: 1 })
|
||||
})
|
||||
|
||||
it('FE-MOB-COLADD-013: without any list the picker explains there is nothing to save into', () => {
|
||||
render(<MCollAddSheet {...baseProps({ collectionId: null, lists: [] })} />)
|
||||
|
||||
expect(screen.getByText('collections.noListsYet')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /common.add/ })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-COLADD-014: closing resets the form so the next open starts blank', async () => {
|
||||
const user = userEvent.setup()
|
||||
const props = baseProps()
|
||||
const { rerender } = render(<MCollAddSheet {...props} />)
|
||||
|
||||
await user.type(screen.getByPlaceholderText('common.name'), 'Alster')
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'common.close' })[0])
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1)
|
||||
|
||||
rerender(<MCollAddSheet {...baseProps({ open: false })} />)
|
||||
rerender(<MCollAddSheet {...baseProps()} />)
|
||||
expect(screen.getByPlaceholderText('common.name')).toHaveValue('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen } from '../../../helpers/render'
|
||||
import MCollCategoryPicker from '../../../../src/mobile/screens/collections/MCollCategoryPicker'
|
||||
import type { Category } from '../../../../src/types'
|
||||
import { useTranslation } from '../../../../src/i18n'
|
||||
|
||||
// FE-MOB-CCATP-001 to FE-MOB-CCATP-010
|
||||
|
||||
const CATEGORIES = [
|
||||
// In CATEGORY_SPEC — takes the design colour/icon, not the DB row.
|
||||
{ id: 1, name: 'Restaurant', color: '#000000', icon: 'MapPin' },
|
||||
// Custom admin category — falls back to its own colour/icon.
|
||||
{ id: 2, name: 'Winery', color: '#123456', icon: 'Wine' },
|
||||
] as unknown as Category[]
|
||||
|
||||
function Harness(props: Omit<ComponentProps<typeof MCollCategoryPicker>, 't'>) {
|
||||
const { t } = useTranslation()
|
||||
return <MCollCategoryPicker {...props} t={t} />
|
||||
}
|
||||
|
||||
function setup(value: number | null, categories: Category[] = CATEGORIES) {
|
||||
const onChange = vi.fn<(id: number | null) => void>()
|
||||
const view = render(<Harness categories={categories} value={value} onChange={onChange} />)
|
||||
const trigger = screen.getAllByRole('button')[0]
|
||||
return { ...view, onChange, trigger }
|
||||
}
|
||||
|
||||
describe('MCollCategoryPicker', () => {
|
||||
it('FE-MOB-CCATP-001: closed with no value it reads "No category" and lists nothing', () => {
|
||||
const { trigger } = setup(null)
|
||||
expect(trigger).toHaveTextContent('No category')
|
||||
expect(screen.getAllByRole('button')).toHaveLength(1)
|
||||
expect(trigger.querySelector('.lucide-chevron-down')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-CCATP-002: a selected category is shown on the trigger', () => {
|
||||
const { trigger } = setup(2)
|
||||
expect(trigger).toHaveTextContent('Winery')
|
||||
})
|
||||
|
||||
it('FE-MOB-CCATP-003: a value with no matching category falls back to "No category"', () => {
|
||||
const { trigger } = setup(99)
|
||||
expect(trigger).toHaveTextContent('No category')
|
||||
})
|
||||
|
||||
it('FE-MOB-CCATP-004: opening lists "No category" plus every category and flips the chevron', () => {
|
||||
const { trigger } = setup(null)
|
||||
fireEvent.click(trigger)
|
||||
const options = screen.getAllByRole('button').slice(1)
|
||||
expect(options.map(o => o.textContent?.trim())).toEqual(['No category', 'Restaurant', 'Winery'])
|
||||
expect(trigger.querySelector('.lucide-chevron-up')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-CCATP-005: picking a category reports its id and closes the list', () => {
|
||||
const { trigger, onChange } = setup(null)
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(screen.getAllByRole('button')[2])
|
||||
expect(onChange).toHaveBeenCalledWith(1)
|
||||
expect(screen.getAllByRole('button')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-CCATP-006: picking "No category" reports null', () => {
|
||||
const { trigger, onChange } = setup(1)
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(screen.getAllByRole('button')[1])
|
||||
expect(onChange).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-MOB-CCATP-007: only the active option carries the check mark', () => {
|
||||
const { trigger } = setup(2)
|
||||
fireEvent.click(trigger)
|
||||
const options = screen.getAllByRole('button').slice(1)
|
||||
expect(options[0].querySelector('.lucide-check')).toBeNull()
|
||||
expect(options[1].querySelector('.lucide-check')).toBeNull()
|
||||
expect(options[2].querySelector('.lucide-check')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-CCATP-008: canonical categories use the design icon, custom ones their own', () => {
|
||||
const { trigger } = setup(null)
|
||||
fireEvent.click(trigger)
|
||||
const options = screen.getAllByRole('button').slice(1)
|
||||
// No category → CircleSlash, Restaurant → Utensils (CATEGORY_SPEC), Winery → its DB icon.
|
||||
expect(options[0].querySelector('.lucide-circle-slash')).not.toBeNull()
|
||||
expect(options[1].querySelector('.lucide-utensils')).not.toBeNull()
|
||||
expect(options[2].querySelector('.lucide-wine')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-CCATP-009: a second tap on the trigger closes the list again', () => {
|
||||
const { trigger } = setup(null)
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.getAllByRole('button')).toHaveLength(4)
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.getAllByRole('button')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-CCATP-010: with no categories the list holds "No category" alone', () => {
|
||||
const { trigger } = setup(null, [])
|
||||
fireEvent.click(trigger)
|
||||
const options = screen.getAllByRole('button').slice(1)
|
||||
expect(options).toHaveLength(1)
|
||||
expect(options[0]).toHaveTextContent('No category')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,304 @@
|
||||
// FE-MOB-COLEDIT-001 to FE-MOB-COLEDIT-013
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen, fireEvent, waitFor } from '../../../helpers/render'
|
||||
import { server } from '../../../helpers/msw/server'
|
||||
import type { Collection } from '@trek/shared'
|
||||
import MCollEditSheet from '../../../../src/mobile/screens/collections/MCollEditSheet'
|
||||
import { useCollectionStore } from '../../../../src/store/collectionStore'
|
||||
import { SWATCH_COLORS } from '../../../../src/mobile/screens/collections/collectionsMobileModel'
|
||||
|
||||
const t = (key: string) => key
|
||||
|
||||
function buildList(over: Partial<Collection> = {}): Collection {
|
||||
return {
|
||||
id: 4,
|
||||
owner_id: 1,
|
||||
name: 'Hamburg',
|
||||
color: '#38BDF8',
|
||||
description: 'Hafenrunde',
|
||||
cover_image: '/uploads/covers/hh.jpg',
|
||||
links: [{ label: 'Guide', url: 'https://hamburg.de' }],
|
||||
is_owner: true,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
type Props = React.ComponentProps<typeof MCollEditSheet>
|
||||
|
||||
function baseProps(overrides: Partial<Props> = {}): Props {
|
||||
return {
|
||||
target: 'new',
|
||||
onClose: vi.fn(),
|
||||
onCreated: vi.fn(),
|
||||
onRequestDelete: vi.fn(),
|
||||
t,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
type CollStore = ReturnType<typeof useCollectionStore.getState>
|
||||
type AddToast = NonNullable<typeof window.__addToast>
|
||||
|
||||
let toast: Mock<AddToast>
|
||||
let createCollection: Mock<CollStore['createCollection']>
|
||||
let updateCollection: Mock<CollStore['updateCollection']>
|
||||
let uploadCover: Mock<CollStore['uploadCover']>
|
||||
|
||||
const origCreateObjectURL = URL.createObjectURL
|
||||
const origRevokeObjectURL = URL.revokeObjectURL
|
||||
|
||||
function stubObjectUrl(fn: unknown) {
|
||||
Object.defineProperty(URL, 'createObjectURL', { writable: true, configurable: true, value: fn })
|
||||
}
|
||||
|
||||
function unsplash(photos: Record<string, unknown>[]) {
|
||||
server.use(http.get('/api/trips/cover-images/search', () => HttpResponse.json({ photos })))
|
||||
}
|
||||
|
||||
/** The cover images carry no alt text, so they are addressed by src. */
|
||||
const imageWithSrc = (src: string) => document.querySelector(`img[src="${src}"]`)
|
||||
|
||||
describe('MCollEditSheet', () => {
|
||||
beforeEach(() => {
|
||||
toast = vi.fn<AddToast>(() => 1)
|
||||
window.__addToast = toast
|
||||
// jsdom's URL.createObjectURL rejects a jsdom File, so the cover preview
|
||||
// needs a stub that just hands back a fake blob URL.
|
||||
stubObjectUrl(vi.fn(() => 'blob:cover'))
|
||||
Object.defineProperty(URL, 'revokeObjectURL', { writable: true, configurable: true, value: vi.fn() })
|
||||
|
||||
createCollection = vi.fn<CollStore['createCollection']>(async () => buildList({ id: 11, name: 'Kopenhagen' }))
|
||||
updateCollection = vi.fn<CollStore['updateCollection']>(async () => undefined)
|
||||
uploadCover = vi.fn<CollStore['uploadCover']>(async () => undefined)
|
||||
useCollectionStore.setState({ createCollection, updateCollection, uploadCover })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__addToast
|
||||
stubObjectUrl(origCreateObjectURL)
|
||||
Object.defineProperty(URL, 'revokeObjectURL', { writable: true, configurable: true, value: origRevokeObjectURL })
|
||||
})
|
||||
|
||||
it('FE-MOB-COLEDIT-001: stays unmounted for a null target and opens in create mode', () => {
|
||||
const { rerender } = render(<MCollEditSheet {...baseProps({ target: null })} />)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
|
||||
rerender(<MCollEditSheet {...baseProps()} />)
|
||||
expect(screen.getByRole('dialog', { name: 'collections.newList' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'collections.create' })).toBeDisabled()
|
||||
// Creating has nothing to delete yet.
|
||||
expect(screen.queryByRole('button', { name: /collections.deleteList/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-COLEDIT-002: creating a list posts name, colour, description and normalised links', async () => {
|
||||
const user = userEvent.setup()
|
||||
const props = baseProps()
|
||||
render(<MCollEditSheet {...props} />)
|
||||
|
||||
await user.type(screen.getByPlaceholderText('collections.listNamePlaceholder'), ' Kopenhagen ')
|
||||
await user.type(screen.getByPlaceholderText('collections.descriptionPlaceholder'), ' Kanaltour ')
|
||||
await user.click(screen.getByRole('button', { name: SWATCH_COLORS[2] }))
|
||||
|
||||
// Two link rows: one bare host that must be normalised, one empty that drops out.
|
||||
await user.click(screen.getByRole('button', { name: /collections.addLink/ }))
|
||||
await user.click(screen.getByRole('button', { name: /collections.addLink/ }))
|
||||
const urlInputs = screen.getAllByPlaceholderText('https://…')
|
||||
await user.type(urlInputs[0], 'visitcopenhagen.com')
|
||||
const labelInputs = screen.getAllByPlaceholderText('collections.linkLabel')
|
||||
await user.type(labelInputs[0], ' Tips ')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'collections.create' }))
|
||||
|
||||
await waitFor(() => expect(createCollection).toHaveBeenCalledTimes(1))
|
||||
expect(createCollection).toHaveBeenCalledWith({
|
||||
name: 'Kopenhagen',
|
||||
color: SWATCH_COLORS[2],
|
||||
description: 'Kanaltour',
|
||||
links: [{ label: 'Tips', url: 'https://visitcopenhagen.com' }],
|
||||
})
|
||||
expect(props.onCreated).toHaveBeenCalledWith(11)
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-COLEDIT-003: a link row can be removed again before saving', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<MCollEditSheet {...baseProps({ target: buildList() })} />)
|
||||
|
||||
expect(screen.getAllByPlaceholderText('https://…')).toHaveLength(1)
|
||||
await user.click(screen.getByRole('button', { name: 'common.delete' }))
|
||||
expect(screen.queryByPlaceholderText('https://…')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-COLEDIT-004: editing seeds the form and saves through updateCollection', async () => {
|
||||
const user = userEvent.setup()
|
||||
const props = baseProps({ target: buildList() })
|
||||
render(<MCollEditSheet {...props} />)
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'collections.editListTitle' })).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('collections.listNamePlaceholder')).toHaveValue('Hamburg')
|
||||
expect(screen.getByPlaceholderText('collections.descriptionPlaceholder')).toHaveValue('Hafenrunde')
|
||||
expect(imageWithSrc('/uploads/covers/hh.jpg')).not.toBeNull()
|
||||
// An existing cover offers to be replaced, not added.
|
||||
expect(screen.getByText('collections.changeCover')).toBeInTheDocument()
|
||||
|
||||
await user.clear(screen.getByPlaceholderText('collections.descriptionPlaceholder'))
|
||||
await user.click(screen.getByRole('button', { name: 'common.save' }))
|
||||
|
||||
await waitFor(() => expect(updateCollection).toHaveBeenCalledTimes(1))
|
||||
expect(updateCollection).toHaveBeenCalledWith(4, {
|
||||
name: 'Hamburg',
|
||||
color: '#38BDF8',
|
||||
description: null,
|
||||
links: [{ label: 'Guide', url: 'https://hamburg.de' }],
|
||||
})
|
||||
// An edit must not re-navigate the way a create does.
|
||||
expect(props.onCreated).not.toHaveBeenCalled()
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-COLEDIT-005: a list without a colour falls back to the first swatch', () => {
|
||||
render(<MCollEditSheet {...baseProps({ target: buildList({ color: null, description: null, links: undefined, cover_image: null }) })} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: SWATCH_COLORS[0] })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(document.querySelector('img')).toBeNull()
|
||||
expect(screen.getByText('collections.addCover')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-COLEDIT-006: picking a file shows the local preview and uploads it after create', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<MCollEditSheet {...baseProps()} />)
|
||||
|
||||
const file = new File(['x'], 'cover.png', { type: 'image/png' })
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
await user.upload(input, file)
|
||||
expect(imageWithSrc('blob:cover')).not.toBeNull()
|
||||
|
||||
await user.type(screen.getByPlaceholderText('collections.listNamePlaceholder'), 'Kopenhagen')
|
||||
await user.click(screen.getByRole('button', { name: 'collections.create' }))
|
||||
|
||||
await waitFor(() => expect(uploadCover).toHaveBeenCalledWith(11, file))
|
||||
})
|
||||
|
||||
it('FE-MOB-COLEDIT-007: an aborted file dialog leaves the cover untouched', () => {
|
||||
render(<MCollEditSheet {...baseProps()} />)
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
const openDialog = vi.spyOn(input, 'click').mockImplementation(() => {})
|
||||
// The cover tile is the trigger for the hidden file input.
|
||||
fireEvent.click(screen.getByText('collections.addCover'))
|
||||
expect(openDialog).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.change(input, { target: { files: [] } })
|
||||
expect(document.querySelector('img')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-COLEDIT-008: the Unsplash search offers photos and the pick is sent as cover_image', async () => {
|
||||
const user = userEvent.setup()
|
||||
unsplash([
|
||||
{ id: 'p1', url: 'https://img/full1.jpg', thumb: 'https://img/t1.jpg', description: 'Canal', photographer: 'Ada' },
|
||||
{ id: 'p2', url: 'https://img/full2.jpg', thumb: 'https://img/t2.jpg' },
|
||||
])
|
||||
render(<MCollEditSheet {...baseProps()} />)
|
||||
|
||||
await user.type(screen.getByPlaceholderText('dashboard.unsplashSearchPlaceholder'), 'copenhagen')
|
||||
await user.click(screen.getByRole('button', { name: /Unsplash/ }))
|
||||
|
||||
const photo = await screen.findByRole('button', { name: 'Ada' })
|
||||
// The photographer credit is shown; a photo without one still renders its thumb.
|
||||
expect(screen.getByText('Ada')).toBeInTheDocument()
|
||||
expect(imageWithSrc('https://img/t2.jpg')).not.toBeNull()
|
||||
|
||||
await user.click(photo)
|
||||
// The picked photo becomes the preview above the grid.
|
||||
expect(imageWithSrc('https://img/full1.jpg')).not.toBeNull()
|
||||
await user.type(screen.getByPlaceholderText('collections.listNamePlaceholder'), 'Kopenhagen')
|
||||
await user.click(screen.getByRole('button', { name: 'collections.create' }))
|
||||
|
||||
await waitFor(() => expect(createCollection).toHaveBeenCalledTimes(1))
|
||||
expect(createCollection).toHaveBeenCalledWith(expect.objectContaining({ cover_image: 'https://img/full1.jpg' }))
|
||||
expect(uploadCover).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-COLEDIT-009: a photo without a url is not selectable and a failing search clears the grid', async () => {
|
||||
const user = userEvent.setup()
|
||||
unsplash([{ id: 'p0', url: '', thumb: 'https://img/t0.jpg', photographer: 'Nobody' }])
|
||||
render(<MCollEditSheet {...baseProps()} />)
|
||||
|
||||
await user.type(screen.getByPlaceholderText('collections.listNamePlaceholder'), 'Kopenhagen')
|
||||
// With no query the list name is used as the search term.
|
||||
await user.click(screen.getByRole('button', { name: /Unsplash/ }))
|
||||
await user.click(await screen.findByRole('button', { name: 'Nobody' }))
|
||||
// Only the thumb is on screen — a url-less photo never becomes the cover.
|
||||
expect(imageWithSrc('https://img/t0.jpg')).not.toBeNull()
|
||||
expect(document.querySelectorAll('img')).toHaveLength(1)
|
||||
|
||||
server.use(http.get('/api/trips/cover-images/search', () => new HttpResponse(null, { status: 500 })))
|
||||
await user.click(screen.getByRole('button', { name: /Unsplash/ }))
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: 'Nobody' })).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('FE-MOB-COLEDIT-010: Unsplash stays idle while there is neither a query nor a name', async () => {
|
||||
const user = userEvent.setup()
|
||||
const search = vi.fn(() => HttpResponse.json({ photos: [] }))
|
||||
server.use(http.get('/api/trips/cover-images/search', search))
|
||||
render(<MCollEditSheet {...baseProps()} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /Unsplash/ })).toBeDisabled()
|
||||
// Enter bypasses the disabled button, so the guard has to hold there too.
|
||||
await user.type(screen.getByPlaceholderText('dashboard.unsplashSearchPlaceholder'), ' {Enter}')
|
||||
expect(search).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-COLEDIT-011: a failed save reports the error and a retry updates the already-created list', async () => {
|
||||
const user = userEvent.setup()
|
||||
uploadCover.mockRejectedValueOnce(Object.assign(new Error('boom'), { response: { data: { error: 'Cover too large' } } }))
|
||||
const props = baseProps()
|
||||
render(<MCollEditSheet {...props} />)
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
await user.upload(input, new File(['x'], 'cover.png', { type: 'image/png' }))
|
||||
await user.type(screen.getByPlaceholderText('collections.listNamePlaceholder'), 'Kopenhagen')
|
||||
await user.click(screen.getByRole('button', { name: 'collections.create' }))
|
||||
|
||||
await waitFor(() => expect(toast).toHaveBeenCalledWith('Cover too large', 'error', undefined))
|
||||
expect(props.onClose).not.toHaveBeenCalled()
|
||||
|
||||
// The list already exists — the retry must not create a second one.
|
||||
await user.click(screen.getByRole('button', { name: 'collections.create' }))
|
||||
await waitFor(() => expect(updateCollection).toHaveBeenCalledWith(11, expect.objectContaining({ name: 'Kopenhagen' })))
|
||||
expect(createCollection).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-COLEDIT-012: a create that returns no list keeps the cover upload from running', async () => {
|
||||
const user = userEvent.setup()
|
||||
createCollection.mockResolvedValueOnce(null)
|
||||
const props = baseProps()
|
||||
render(<MCollEditSheet {...props} />)
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
await user.upload(input, new File(['x'], 'cover.png', { type: 'image/png' }))
|
||||
await user.type(screen.getByPlaceholderText('collections.listNamePlaceholder'), 'Kopenhagen')
|
||||
await user.click(screen.getByRole('button', { name: 'collections.create' }))
|
||||
|
||||
await waitFor(() => expect(props.onClose).toHaveBeenCalledTimes(1))
|
||||
expect(uploadCover).not.toHaveBeenCalled()
|
||||
expect(props.onCreated).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-COLEDIT-013: the owner can delete from the footer, a co-owner cannot', () => {
|
||||
const props = baseProps({ target: buildList() })
|
||||
const { rerender } = render(<MCollEditSheet {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /collections.deleteList/ }))
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1)
|
||||
expect(props.onRequestDelete).toHaveBeenCalledWith(4)
|
||||
|
||||
rerender(<MCollEditSheet {...baseProps({ target: buildList({ id: 8, name: 'Julien Tipps', is_owner: false }) })} />)
|
||||
expect(screen.queryByRole('button', { name: /collections.deleteList/ })).not.toBeInTheDocument()
|
||||
// Switching target re-seeds the form.
|
||||
expect(screen.getByPlaceholderText('collections.listNamePlaceholder')).toHaveValue('Julien Tipps')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,212 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
import MCollLabelsSheet from '../../../../src/mobile/screens/collections/MCollLabelsSheet'
|
||||
import type { LabelOption } from '../../../../src/pages/collections/collectionsModel'
|
||||
import { SWATCH_COLORS } from '../../../../src/mobile/screens/collections/collectionsMobileModel'
|
||||
import { useTranslation } from '../../../../src/i18n'
|
||||
|
||||
// FE-MOB-CLBLS-001 to FE-MOB-CLBLS-018
|
||||
|
||||
const LABELS: LabelOption[] = [
|
||||
{ id: 1, name: 'Food', color: '#EF4444', count: 4 },
|
||||
// No colour — falls back to the first swatch.
|
||||
{ id: 2, name: 'Museums', color: null, count: 0 },
|
||||
]
|
||||
|
||||
function Harness(props: Omit<ComponentProps<typeof MCollLabelsSheet>, 't'>) {
|
||||
const { t } = useTranslation()
|
||||
return <MCollLabelsSheet {...props} t={t} />
|
||||
}
|
||||
|
||||
type Props = ComponentProps<typeof MCollLabelsSheet>
|
||||
|
||||
function setup(over: Partial<Props> = {}) {
|
||||
const onCreate = vi.fn<Props['onCreate']>().mockResolvedValue(undefined)
|
||||
const onUpdate = vi.fn<Props['onUpdate']>().mockResolvedValue(undefined)
|
||||
const onDelete = vi.fn<Props['onDelete']>().mockResolvedValue(undefined)
|
||||
const onAssign = vi.fn<Props['onAssign']>().mockResolvedValue(undefined)
|
||||
const onSwitchToManage = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
const props = {
|
||||
open: true,
|
||||
mode: 'manage' as const,
|
||||
labels: LABELS,
|
||||
selectedCount: 2,
|
||||
onCreate,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onAssign,
|
||||
onSwitchToManage,
|
||||
onClose,
|
||||
...over,
|
||||
}
|
||||
const view = render(<Harness {...props} />)
|
||||
return { ...view, onCreate, onUpdate, onDelete, onAssign, onSwitchToManage, onClose, props }
|
||||
}
|
||||
|
||||
describe('MCollLabelsSheet', () => {
|
||||
it('FE-MOB-CLBLS-001: manage mode heads the sheet and lists every label with its usage count', () => {
|
||||
setup()
|
||||
expect(screen.getByRole('dialog', { name: 'Manage labels' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Food')).toBeInTheDocument()
|
||||
expect(screen.getByText('4')).toBeInTheDocument()
|
||||
expect(screen.getByText('Museums')).toBeInTheDocument()
|
||||
expect(screen.getByText('0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CLBLS-002: the add button stays disabled until the name has content', () => {
|
||||
setup()
|
||||
const add = screen.getByRole('button', { name: /Add label/ })
|
||||
expect(add).toBeDisabled()
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Berlin'), { target: { value: ' ' } })
|
||||
expect(add).toBeDisabled()
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Berlin'), { target: { value: 'Bars' } })
|
||||
expect(add).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-CLBLS-003: creating a label trims the name and sends the default swatch', async () => {
|
||||
const { onCreate } = setup()
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Berlin'), { target: { value: ' Bars ' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add label/ }))
|
||||
await waitFor(() => expect(onCreate).toHaveBeenCalledWith('Bars', SWATCH_COLORS[0]))
|
||||
// The form resets for the next label.
|
||||
await waitFor(() => expect(screen.getByPlaceholderText('e.g. Berlin')).toHaveValue(''))
|
||||
})
|
||||
|
||||
it('FE-MOB-CLBLS-004: the picked swatch is sent along and marked pressed', async () => {
|
||||
const { onCreate } = setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: SWATCH_COLORS[3] }))
|
||||
expect(screen.getByRole('button', { name: SWATCH_COLORS[3] })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByRole('button', { name: SWATCH_COLORS[0] })).toHaveAttribute('aria-pressed', 'false')
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Berlin'), { target: { value: 'Bars' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add label/ }))
|
||||
await waitFor(() => expect(onCreate).toHaveBeenCalledWith('Bars', SWATCH_COLORS[3]))
|
||||
})
|
||||
|
||||
it('FE-MOB-CLBLS-005: Enter in the name field submits, other keys do not', async () => {
|
||||
const { onCreate } = setup()
|
||||
const input = screen.getByPlaceholderText('e.g. Berlin')
|
||||
fireEvent.change(input, { target: { value: 'Bars' } })
|
||||
fireEvent.keyDown(input, { key: 'a' })
|
||||
expect(onCreate).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
await waitFor(() => expect(onCreate).toHaveBeenCalledWith('Bars', SWATCH_COLORS[0]))
|
||||
})
|
||||
|
||||
it('FE-MOB-CLBLS-006: a blank name never reaches the callback', () => {
|
||||
const { onCreate } = setup()
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('e.g. Berlin'), { key: 'Enter' })
|
||||
expect(onCreate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-CLBLS-007: the pencil seeds the form and switches the button to Save', async () => {
|
||||
const { onUpdate, onCreate } = setup()
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Edit' })[0])
|
||||
expect(screen.getByPlaceholderText('e.g. Berlin')).toHaveValue('Food')
|
||||
expect(screen.getByRole('button', { name: '#EF4444' })).toHaveAttribute('aria-pressed', 'true')
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Berlin'), { target: { value: 'Eating' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(onUpdate).toHaveBeenCalledWith(1, { name: 'Eating', color: '#EF4444' }))
|
||||
expect(onCreate).not.toHaveBeenCalled()
|
||||
// Back to create mode once the save has settled.
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /Add label/ })).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('FE-MOB-CLBLS-008: editing a colourless label starts from the first swatch', () => {
|
||||
setup()
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Edit' })[1])
|
||||
expect(screen.getByPlaceholderText('e.g. Berlin')).toHaveValue('Museums')
|
||||
expect(screen.getByRole('button', { name: SWATCH_COLORS[0] })).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
it('FE-MOB-CLBLS-009: the trash icon deletes that label', () => {
|
||||
const { onDelete } = setup()
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Delete' })[1])
|
||||
expect(onDelete).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
it('FE-MOB-CLBLS-010: a second submit is swallowed while the first is still running', async () => {
|
||||
let resolve!: () => void
|
||||
const onCreate = vi.fn<Props['onCreate']>().mockReturnValue(new Promise<void>(r => { resolve = r }))
|
||||
setup({ onCreate })
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Berlin'), { target: { value: 'Bars' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add label/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add label/ }))
|
||||
expect(onCreate).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolve()
|
||||
await waitFor(() => expect(screen.getByPlaceholderText('e.g. Berlin')).toHaveValue(''))
|
||||
})
|
||||
|
||||
it('FE-MOB-CLBLS-011: manage mode with no labels shows the empty line without the assign hint', () => {
|
||||
setup({ labels: [] })
|
||||
expect(screen.getByText('No labels yet')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Create a label first to group places in this list.')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CLBLS-012: assign mode titles with the selection size and drops the create form', () => {
|
||||
setup({ mode: 'assign' })
|
||||
expect(screen.getByRole('dialog', { name: 'Add labels to 2 places' })).toBeInTheDocument()
|
||||
expect(screen.queryByPlaceholderText('e.g. Berlin')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Assign label/ })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-CLBLS-013: assign mode toggles labels as checkboxes and sends the checked ids', async () => {
|
||||
const { onAssign } = setup({ mode: 'assign' })
|
||||
const boxes = screen.getAllByRole('checkbox')
|
||||
expect(boxes[0]).toHaveAttribute('aria-checked', 'false')
|
||||
|
||||
fireEvent.click(boxes[0])
|
||||
fireEvent.click(boxes[1])
|
||||
expect(boxes[0]).toHaveAttribute('aria-checked', 'true')
|
||||
expect(boxes[0].querySelector('.lucide-check')).not.toBeNull()
|
||||
|
||||
// Untick the second one again — only the first is submitted.
|
||||
fireEvent.click(boxes[1])
|
||||
expect(boxes[1]).toHaveAttribute('aria-checked', 'false')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Assign label/ }))
|
||||
await waitFor(() => expect(onAssign).toHaveBeenCalledWith([1]))
|
||||
})
|
||||
|
||||
it('FE-MOB-CLBLS-014: assigning nothing never reaches the callback', () => {
|
||||
const { onAssign } = setup({ mode: 'assign' })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Assign label/ }))
|
||||
expect(onAssign).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-CLBLS-015: the footer link switches over to label management', () => {
|
||||
const { onSwitchToManage } = setup({ mode: 'assign' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Manage labels' }))
|
||||
expect(onSwitchToManage).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-CLBLS-016: assign mode with no labels adds the "create one first" hint', () => {
|
||||
setup({ mode: 'assign', labels: [] })
|
||||
expect(screen.getByText('No labels yet')).toBeInTheDocument()
|
||||
expect(screen.getByText('Create a label first to group places in this list.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CLBLS-017: closing the sheet clears the draft, and the header close hands back', () => {
|
||||
const { rerender, props, onClose } = setup()
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Berlin'), { target: { value: 'Bars' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
|
||||
rerender(<Harness {...props} open={false} />)
|
||||
rerender(<Harness {...props} open />)
|
||||
expect(screen.getByPlaceholderText('e.g. Berlin')).toHaveValue('')
|
||||
expect(screen.getByRole('button', { name: SWATCH_COLORS[0] })).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
it('FE-MOB-CLBLS-018: a closed sheet renders nothing at all', () => {
|
||||
setup({ open: false })
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { CollectionLink } from '@trek/shared'
|
||||
import { fireEvent, render, screen } from '../../../helpers/render'
|
||||
import MCollLinksEditor from '../../../../src/mobile/screens/collections/MCollLinksEditor'
|
||||
import { useTranslation } from '../../../../src/i18n'
|
||||
|
||||
// FE-MOB-CLNKED-001 to FE-MOB-CLNKED-006
|
||||
// The editor reads its copy through the real TranslationProvider, so the
|
||||
// assertions use the English strings.
|
||||
|
||||
function Harness(props: Omit<ComponentProps<typeof MCollLinksEditor>, 't'>) {
|
||||
const { t } = useTranslation()
|
||||
return <MCollLinksEditor {...props} t={t} />
|
||||
}
|
||||
|
||||
function setup(links: CollectionLink[]) {
|
||||
const onChange = vi.fn<(next: CollectionLink[]) => void>()
|
||||
const view = render(<Harness links={links} onChange={onChange} />)
|
||||
return { ...view, onChange }
|
||||
}
|
||||
|
||||
const LINKS: CollectionLink[] = [
|
||||
{ label: 'Menu', url: 'https://example.com/menu' },
|
||||
{ url: 'https://booking.com/hotel' },
|
||||
]
|
||||
|
||||
describe('MCollLinksEditor', () => {
|
||||
it('FE-MOB-CLNKED-001: renders one label/url pair per link and an add row', () => {
|
||||
setup(LINKS)
|
||||
const labels = screen.getAllByPlaceholderText('Label')
|
||||
const urls = screen.getAllByPlaceholderText('https://…')
|
||||
expect(labels).toHaveLength(2)
|
||||
expect(labels[0]).toHaveValue('Menu')
|
||||
// A link without a label still renders an empty (controlled) input.
|
||||
expect(labels[1]).toHaveValue('')
|
||||
expect(urls[0]).toHaveValue('https://example.com/menu')
|
||||
expect(urls[1]).toHaveValue('https://booking.com/hotel')
|
||||
expect(screen.getByRole('button', { name: /Add link/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CLNKED-002: editing a label patches only that row', () => {
|
||||
const { onChange } = setup(LINKS)
|
||||
fireEvent.change(screen.getAllByPlaceholderText('Label')[1], { target: { value: 'Hotel' } })
|
||||
expect(onChange).toHaveBeenCalledWith([
|
||||
{ label: 'Menu', url: 'https://example.com/menu' },
|
||||
{ label: 'Hotel', url: 'https://booking.com/hotel' },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-MOB-CLNKED-003: editing a url patches only that row', () => {
|
||||
const { onChange } = setup(LINKS)
|
||||
fireEvent.change(screen.getAllByPlaceholderText('https://…')[0], { target: { value: 'https://example.com/card' } })
|
||||
expect(onChange).toHaveBeenCalledWith([
|
||||
{ label: 'Menu', url: 'https://example.com/card' },
|
||||
{ url: 'https://booking.com/hotel' },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-MOB-CLNKED-004: the trash button drops that row', () => {
|
||||
const { onChange } = setup(LINKS)
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Delete' })[0])
|
||||
expect(onChange).toHaveBeenCalledWith([{ url: 'https://booking.com/hotel' }])
|
||||
})
|
||||
|
||||
it('FE-MOB-CLNKED-005: the add row appends an empty link', () => {
|
||||
const { onChange } = setup(LINKS)
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add link/ }))
|
||||
expect(onChange).toHaveBeenCalledWith([...LINKS, { url: '' }])
|
||||
})
|
||||
|
||||
it('FE-MOB-CLNKED-006: an empty list shows the add row only', () => {
|
||||
const { onChange } = setup([])
|
||||
expect(screen.queryByPlaceholderText('Label')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add link/ }))
|
||||
expect(onChange).toHaveBeenCalledWith([{ url: '' }])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Collection } from '@trek/shared'
|
||||
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
import MCollListPickerSheet, {
|
||||
type MCollListPickerMode,
|
||||
} from '../../../../src/mobile/screens/collections/MCollListPickerSheet'
|
||||
import { useTranslation } from '../../../../src/i18n'
|
||||
|
||||
// FE-MOB-CLISTP-001 to FE-MOB-CLISTP-010
|
||||
|
||||
const LISTS = [
|
||||
{ id: 1, owner_id: 3, name: 'Tokyo 2026', color: '#38BDF8', place_count: 12 },
|
||||
// No colour and no count — both fall back.
|
||||
{ id: 2, owner_id: 3, name: 'Someday' },
|
||||
] as unknown as Collection[]
|
||||
|
||||
function Harness(props: Omit<ComponentProps<typeof MCollListPickerSheet>, 't'>) {
|
||||
const { t } = useTranslation()
|
||||
return <MCollListPickerSheet {...props} t={t} />
|
||||
}
|
||||
|
||||
function setup(over: Partial<ComponentProps<typeof MCollListPickerSheet>> = {}) {
|
||||
const onPick = vi.fn<(id: number) => Promise<void>>().mockResolvedValue(undefined)
|
||||
const onClose = vi.fn()
|
||||
const props = { mode: 'move' as MCollListPickerMode | null, lists: LISTS, count: 3, onPick, onClose, ...over }
|
||||
const view = render(<Harness {...props} />)
|
||||
return { ...view, onPick, onClose, props }
|
||||
}
|
||||
|
||||
describe('MCollListPickerSheet', () => {
|
||||
it('FE-MOB-CLISTP-001: move mode titles the sheet with the selection size', () => {
|
||||
setup()
|
||||
expect(screen.getByRole('dialog', { name: 'Move 3 to another list' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Move 3 to another list')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CLISTP-002: copy mode uses the duplicate wording', () => {
|
||||
setup({ mode: 'copy', count: 1 })
|
||||
expect(screen.getByRole('dialog', { name: 'Duplicate 1 to another list' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CLISTP-003: renders one row per list with its place count', () => {
|
||||
setup()
|
||||
expect(screen.getByRole('button', { name: /Tokyo 2026/ })).toHaveTextContent('12')
|
||||
expect(screen.getByRole('button', { name: /Someday/ })).toHaveTextContent('0')
|
||||
})
|
||||
|
||||
it('FE-MOB-CLISTP-004: an empty target list explains there is nowhere to move to', () => {
|
||||
setup({ lists: [] })
|
||||
expect(screen.getByText('No other lists yet')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CLISTP-005: picking a list hands the target id to the caller', async () => {
|
||||
const { onPick } = setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: /Tokyo 2026/ }))
|
||||
await waitFor(() => expect(onPick).toHaveBeenCalledWith(1))
|
||||
})
|
||||
|
||||
it('FE-MOB-CLISTP-006: while a pick runs the row spins and every row is locked', async () => {
|
||||
let resolve!: () => void
|
||||
const onPick = vi.fn<(id: number) => Promise<void>>().mockReturnValue(new Promise<void>(r => { resolve = r }))
|
||||
setup({ onPick })
|
||||
const first = screen.getByRole('button', { name: /Tokyo 2026/ })
|
||||
fireEvent.click(first)
|
||||
|
||||
expect(first.querySelector('.animate-spin')).not.toBeNull()
|
||||
expect(screen.getByRole('button', { name: /Someday/ })).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: /Someday/ }))
|
||||
expect(onPick).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolve()
|
||||
await waitFor(() => expect(first.querySelector('.animate-spin')).toBeNull())
|
||||
})
|
||||
|
||||
it('FE-MOB-CLISTP-007: once a pick has settled another list can be picked', async () => {
|
||||
const { onPick } = setup({ mode: 'copy' })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Tokyo 2026/ }))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /Someday/ })).not.toBeDisabled())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Someday/ }))
|
||||
await waitFor(() => expect(onPick).toHaveBeenNthCalledWith(2, 2))
|
||||
})
|
||||
|
||||
it('FE-MOB-CLISTP-008: the header close hands back to the caller', () => {
|
||||
const { onClose } = setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-CLISTP-009: mode null keeps the sheet unmounted', () => {
|
||||
setup({ mode: null })
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CLISTP-010: the title follows the mode it is opened with, and is held on close', () => {
|
||||
const { rerender, props } = setup({ mode: null })
|
||||
rerender(<Harness {...props} mode="copy" />)
|
||||
expect(screen.getByRole('dialog', { name: 'Duplicate 3 to another list' })).toBeInTheDocument()
|
||||
|
||||
// Closing keeps the last title through the exit animation.
|
||||
rerender(<Harness {...props} mode={null} />)
|
||||
expect(screen.getByText('Duplicate 3 to another list')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
// FE-MOB-COLDRAW-001 to FE-MOB-COLDRAW-008
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import type { Collection } from '@trek/shared'
|
||||
import { render, screen, fireEvent } from '../../../helpers/render'
|
||||
import MCollListsDrawer from '../../../../src/mobile/screens/collections/MCollListsDrawer'
|
||||
import { ALL_SAVED } from '../../../../src/store/collectionStore'
|
||||
import type { IncomingCollectionInvite } from '../../../../src/store/collectionStore'
|
||||
|
||||
const t = (key: string) => key
|
||||
|
||||
function buildList(over: Partial<Collection> = {}): Collection {
|
||||
return { id: 1, owner_id: 1, name: 'Hamburg', color: '#38BDF8', place_count: 4, is_owner: true, ...over }
|
||||
}
|
||||
|
||||
const invite: IncomingCollectionInvite = {
|
||||
collection_id: 42,
|
||||
name: 'Roadtrip 2027',
|
||||
from: { id: 9, username: 'julien' },
|
||||
}
|
||||
|
||||
type Props = React.ComponentProps<typeof MCollListsDrawer>
|
||||
|
||||
function renderDrawer(overrides: Partial<Props> = {}) {
|
||||
const props: Props = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
ownedLists: [buildList()],
|
||||
sharedLists: [],
|
||||
activeId: ALL_SAVED,
|
||||
incomingInvites: [],
|
||||
onSelect: vi.fn(),
|
||||
onNewList: vi.fn(),
|
||||
onAcceptInvite: vi.fn(),
|
||||
onDeclineInvite: vi.fn(),
|
||||
t,
|
||||
...overrides,
|
||||
}
|
||||
render(<MCollListsDrawer {...props} />)
|
||||
return props
|
||||
}
|
||||
|
||||
describe('MCollListsDrawer', () => {
|
||||
it('FE-MOB-COLDRAW-001: renders nothing while closed', () => {
|
||||
renderDrawer({ open: false })
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-COLDRAW-002: lists the owned lists with their place counts above the union entry', () => {
|
||||
renderDrawer({
|
||||
ownedLists: [buildList(), buildList({ id: 2, name: 'Kopenhagen', place_count: undefined })],
|
||||
})
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'collections.title' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Hamburg')).toBeInTheDocument()
|
||||
expect(screen.getByText('4')).toBeInTheDocument()
|
||||
// A list the server did not count yet falls back to 0.
|
||||
expect(screen.getByText('Kopenhagen')).toBeInTheDocument()
|
||||
expect(screen.getByText('0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-COLDRAW-003: selecting the union and a list reports the right id', () => {
|
||||
const props = renderDrawer()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /collections.allSaved/ }))
|
||||
expect(props.onSelect).toHaveBeenCalledWith(ALL_SAVED)
|
||||
|
||||
fireEvent.click(screen.getByText('Hamburg'))
|
||||
expect(props.onSelect).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-COLDRAW-004: the new-list and close buttons call their handlers', () => {
|
||||
const props = renderDrawer()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /collections.newList/ }))
|
||||
expect(props.onNewList).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.close' }))
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-COLDRAW-005: the active list is highlighted, the union entry is not', () => {
|
||||
renderDrawer({ activeId: 1 })
|
||||
|
||||
const row = screen.getByText('Hamburg').closest('button') as HTMLElement
|
||||
expect(row.className).toContain('bg-[color:var(--m-ic)]')
|
||||
expect(row.style.boxShadow).toContain('inset 0 0 0 1.5px')
|
||||
|
||||
const union = screen.getByRole('button', { name: /collections.allSaved/ })
|
||||
expect(union.className).not.toContain('bg-[color:var(--m-ic)]')
|
||||
})
|
||||
|
||||
it('FE-MOB-COLDRAW-006: shared lists get their own section, absent when there are none', () => {
|
||||
renderDrawer()
|
||||
expect(screen.queryByText('collections.shared')).not.toBeInTheDocument()
|
||||
|
||||
renderDrawer({ sharedLists: [buildList({ id: 7, name: 'Julien Tipps', color: null, is_owner: false })] })
|
||||
expect(screen.getByText('collections.shared')).toBeInTheDocument()
|
||||
expect(screen.getByText('Julien Tipps')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-COLDRAW-007: pending invites show the inviter and accept/decline by collection id', () => {
|
||||
const props = renderDrawer({ incomingInvites: [invite] })
|
||||
|
||||
expect(screen.getByText('collections.invites.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('Roadtrip 2027')).toBeInTheDocument()
|
||||
expect(screen.getByText(/julien/)).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collections.invites.accept' }))
|
||||
expect(props.onAcceptInvite).toHaveBeenCalledWith(42)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collections.invites.decline' }))
|
||||
expect(props.onDeclineInvite).toHaveBeenCalledWith(42)
|
||||
})
|
||||
|
||||
it('FE-MOB-COLDRAW-008: a list without a colour falls back to the default swatch', () => {
|
||||
renderDrawer({ ownedLists: [buildList({ color: null })] })
|
||||
|
||||
const dot = screen.getByText('Hamburg').previousElementSibling as HTMLElement
|
||||
expect(dot).toHaveStyle({ background: '#6366F1' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,381 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import type { CollectionLabel, CollectionPlace } from '@trek/shared'
|
||||
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
import MCollPlaceSheet from '../../../../src/mobile/screens/collections/MCollPlaceSheet'
|
||||
import { mapsApi } from '../../../../src/api/client'
|
||||
import type { Category } from '../../../../src/types'
|
||||
import { resetAllStores } from '../../../helpers/store'
|
||||
import { useTranslation } from '../../../../src/i18n'
|
||||
|
||||
// FE-MOB-CPLSH-001 to FE-MOB-CPLSH-033
|
||||
|
||||
// react-markdown ships ESM-only chunks jsdom chokes on; the sheet only needs
|
||||
// the raw description text to reach the renderer.
|
||||
vi.mock('react-markdown', () => ({
|
||||
default: ({ children }: { children: string }) => <span data-testid="md">{children}</span>,
|
||||
}))
|
||||
vi.mock('remark-gfm', () => ({ default: () => ({}) }))
|
||||
vi.mock('remark-breaks', () => ({ default: () => ({}) }))
|
||||
|
||||
type Props = ComponentProps<typeof MCollPlaceSheet>
|
||||
|
||||
const CATEGORIES = [
|
||||
{ id: 3, name: 'Restaurant', color: '#E8843D', icon: 'Utensils' },
|
||||
{ id: 5, name: 'Hotel', color: '#9B5DE5', icon: 'BedDouble' },
|
||||
] as unknown as Category[]
|
||||
|
||||
const LABELS: CollectionLabel[] = [
|
||||
{ id: 11, collection_id: 1, name: 'Must see', color: '#EF4444' },
|
||||
{ id: 12, collection_id: 1, name: 'Rainy day', color: null },
|
||||
]
|
||||
|
||||
function place(over: Partial<CollectionPlace> = {}): CollectionPlace {
|
||||
return {
|
||||
id: 7,
|
||||
collection_id: 1,
|
||||
name: 'Narisawa',
|
||||
address: '2-6-15 Minami-Aoyama',
|
||||
status: 'want',
|
||||
category_id: 3,
|
||||
category: { id: 3, name: 'Restaurant', color: '#E8843D', icon: 'Utensils' },
|
||||
description: 'Two stars, book early',
|
||||
links: [{ label: 'Menu', url: 'https://narisawa.example/menu' }, { url: 'https://www.booking.example/x' }],
|
||||
label_ids: [11],
|
||||
...over,
|
||||
} as CollectionPlace
|
||||
}
|
||||
|
||||
type AddToast = NonNullable<Window['__addToast']>
|
||||
let addToast: Mock<AddToast>
|
||||
|
||||
function Harness(props: Omit<Props, 't'>) {
|
||||
const { t } = useTranslation()
|
||||
return <MCollPlaceSheet {...props} t={t} />
|
||||
}
|
||||
|
||||
function setup(over: Partial<Props> = {}) {
|
||||
const onClose = vi.fn()
|
||||
const onSetStatus = vi.fn()
|
||||
const onSave = vi.fn<Props['onSave']>().mockResolvedValue(undefined)
|
||||
const onCopyToTrip = vi.fn()
|
||||
const onRemove = vi.fn()
|
||||
const props: Omit<Props, 't'> = {
|
||||
place: place(),
|
||||
canEdit: true,
|
||||
canDelete: true,
|
||||
categories: CATEGORIES,
|
||||
labels: LABELS,
|
||||
onClose,
|
||||
onSetStatus,
|
||||
onSave,
|
||||
onCopyToTrip,
|
||||
onRemove,
|
||||
...over,
|
||||
}
|
||||
const view = render(<Harness {...props} />)
|
||||
return { ...view, onClose, onSetStatus, onSave, onCopyToTrip, onRemove, props }
|
||||
}
|
||||
|
||||
function pngFile(name = 'cover.png') {
|
||||
return new File(['x'], name, { type: 'image/png' })
|
||||
}
|
||||
|
||||
describe('MCollPlaceSheet', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
addToast = vi.fn<AddToast>()
|
||||
window.__addToast = addToast
|
||||
vi.spyOn(mapsApi, 'placePhoto').mockResolvedValue({ photoUrl: null })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
delete window.__addToast
|
||||
})
|
||||
|
||||
it('FE-MOB-CPLSH-001: heads the sheet with the place, its category chip and address', () => {
|
||||
setup()
|
||||
expect(screen.getByRole('dialog', { name: 'Narisawa' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Narisawa')).toBeInTheDocument()
|
||||
expect(screen.getByText('Restaurant')).toBeInTheDocument()
|
||||
expect(screen.getByText('2-6-15 Minami-Aoyama')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CPLSH-002: null place keeps the sheet unmounted', () => {
|
||||
setup({ place: null })
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CPLSH-003: a place without a category or address drops both rows', () => {
|
||||
setup({ place: place({ category: undefined, category_id: null, address: null }) })
|
||||
expect(screen.queryByText('Restaurant')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('2-6-15 Minami-Aoyama')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CPLSH-004: the status cycle marks the current status and reports a new one', () => {
|
||||
const { onSetStatus } = setup()
|
||||
expect(screen.getByRole('button', { name: 'Want to go' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByRole('button', { name: 'Idea' })).toHaveAttribute('aria-pressed', 'false')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Visited' }))
|
||||
expect(onSetStatus).toHaveBeenCalledWith('visited')
|
||||
})
|
||||
|
||||
it('FE-MOB-CPLSH-005: a read-only member cannot change the status or edit', () => {
|
||||
setup({ canEdit: false })
|
||||
expect(screen.getByRole('button', { name: 'Visited' })).toBeDisabled()
|
||||
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CPLSH-006: assigned labels are shown, unassigned ones are not', () => {
|
||||
setup()
|
||||
expect(screen.getByText('Must see')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Rainy day')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CPLSH-007: the description is rendered as markdown', () => {
|
||||
setup()
|
||||
expect(screen.getByTestId('md')).toHaveTextContent('Two stars, book early')
|
||||
})
|
||||
|
||||
it('FE-MOB-CPLSH-008: links show their label, falling back to the bare host', () => {
|
||||
setup()
|
||||
const menu = screen.getByRole('link', { name: 'Menu' })
|
||||
expect(menu).toHaveAttribute('href', 'https://narisawa.example/menu')
|
||||
expect(menu).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
expect(screen.getByRole('link', { name: 'booking.example' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CPLSH-009: a malformed link url falls back to the raw string', () => {
|
||||
setup({ place: place({ links: [{ url: 'not a url' }] }) })
|
||||
expect(screen.getByRole('link', { name: 'not a url' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CPLSH-010: copy-to-trip, remove and close hand back to the screen', () => {
|
||||
const { onCopyToTrip, onRemove, onClose } = setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy to trip' }))
|
||||
expect(onCopyToTrip).toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove from list' }))
|
||||
expect(onRemove).toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-CPLSH-011: without delete rights the remove action is gone', () => {
|
||||
setup({ canDelete: false })
|
||||
expect(screen.queryByRole('button', { name: 'Remove from list' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CPLSH-012: the edit form is seeded from the place', () => {
|
||||
setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
expect(screen.getByDisplayValue('Narisawa')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Two stars, book early')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('https://narisawa.example/menu')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Must see' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByRole('button', { name: 'Rainy day' })).toHaveAttribute('aria-pressed', 'false')
|
||||
// The read-only address disappears while editing.
|
||||
expect(screen.queryByText('2-6-15 Minami-Aoyama')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CPLSH-013: saving sends the trimmed name, labels, category and normalised links', async () => {
|
||||
const { onSave } = setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
fireEvent.change(screen.getByDisplayValue('Narisawa'), { target: { value: ' Narisawa Tokyo ' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Rainy day' }))
|
||||
fireEvent.change(screen.getByDisplayValue('https://www.booking.example/x'), { target: { value: 'booking.example/y' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith({
|
||||
name: 'Narisawa Tokyo',
|
||||
description: 'Two stars, book early',
|
||||
links: [
|
||||
{ label: 'Menu', url: 'https://narisawa.example/menu' },
|
||||
{ label: undefined, url: 'https://booking.example/y' },
|
||||
],
|
||||
category_id: 3,
|
||||
label_ids: [11, 12],
|
||||
}))
|
||||
// Back to the read view.
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('FE-MOB-CPLSH-014: an emptied name keeps the old one and a blank description clears it', async () => {
|
||||
const { onSave } = setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
fireEvent.change(screen.getByDisplayValue('Narisawa'), { target: { value: ' ' } })
|
||||
fireEvent.change(screen.getByDisplayValue('Two stars, book early'), { target: { value: ' ' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
name: 'Narisawa',
|
||||
description: null,
|
||||
})))
|
||||
})
|
||||
|
||||
it('FE-MOB-CPLSH-015: blank link rows are dropped before saving', async () => {
|
||||
const { onSave } = setup({ place: place({ links: [{ url: ' ' }] }) })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ links: [] })))
|
||||
})
|
||||
|
||||
it('FE-MOB-CPLSH-016: a failed save keeps the form open and shows the server message', async () => {
|
||||
const onSave = vi.fn<Props['onSave']>().mockRejectedValue({ response: { data: { error: 'Name taken' } } })
|
||||
setup({ onSave })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Name taken', 'error', undefined))
|
||||
expect(screen.getByDisplayValue('Narisawa')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CPLSH-017: the save button is locked while the request runs', async () => {
|
||||
let resolve!: () => void
|
||||
const onSave = vi.fn<Props['onSave']>().mockReturnValue(new Promise<void>(r => { resolve = r }))
|
||||
setup({ onSave })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
|
||||
resolve()
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('FE-MOB-CPLSH-018: cancel throws the edits away and returns to the read view', () => {
|
||||
const { onSave } = setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
fireEvent.change(screen.getByDisplayValue('Narisawa'), { target: { value: 'Something else' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
expect(screen.getByDisplayValue('Narisawa')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CPLSH-019: a list without labels hides the label picker in edit mode', () => {
|
||||
setup({ labels: [] })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
expect(screen.queryByRole('button', { name: 'Must see' })).not.toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Narisawa')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CPLSH-020: the stored cover wins over the auto photo lookup', () => {
|
||||
setup({ place: place({ image_url: '/uploads/places/n.jpg', google_place_id: 'gp1' }) })
|
||||
expect(document.querySelector('img')).toHaveAttribute('src', '/uploads/places/n.jpg')
|
||||
expect(mapsApi.placePhoto).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-CPLSH-021: without a cover the maps photo is fetched by place id', async () => {
|
||||
vi.mocked(mapsApi.placePhoto).mockResolvedValue({ photoUrl: 'https://photos.example/n.jpg' })
|
||||
setup({ place: place({ google_place_id: 'gp1' }) })
|
||||
await waitFor(() => expect(document.querySelector('img')).toHaveAttribute('src', 'https://photos.example/n.jpg'))
|
||||
expect(mapsApi.placePhoto).toHaveBeenCalledWith('gp1', undefined, undefined, 'Narisawa')
|
||||
})
|
||||
|
||||
it('FE-MOB-CPLSH-022: coordinates stand in when there is no provider id', async () => {
|
||||
setup({ place: place({ lat: 35.6, lng: 139.7 }) })
|
||||
await waitFor(() => expect(mapsApi.placePhoto).toHaveBeenCalledWith('35.6,139.7', 35.6, 139.7, 'Narisawa'))
|
||||
// A null photoUrl leaves the gradient hero in place.
|
||||
expect(document.querySelector('img')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-CPLSH-023: a place with neither id nor coordinates never asks for a photo', () => {
|
||||
setup()
|
||||
expect(mapsApi.placePhoto).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-CPLSH-024: a failing photo lookup is swallowed', async () => {
|
||||
vi.mocked(mapsApi.placePhoto).mockRejectedValue(new Error('quota'))
|
||||
setup({ place: place({ osm_id: 'node/1' }) })
|
||||
await waitFor(() => expect(mapsApi.placePhoto).toHaveBeenCalled())
|
||||
expect(document.querySelector('img')).toBeNull()
|
||||
expect(screen.getByText('Narisawa')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CPLSH-025: the camera button needs both an upload handler and edit rights', () => {
|
||||
const { rerender, props } = setup()
|
||||
expect(screen.queryByRole('button', { name: 'Upload image' })).not.toBeInTheDocument()
|
||||
|
||||
rerender(<Harness {...props} onUploadImage={vi.fn()} canEdit={false} />)
|
||||
expect(screen.queryByRole('button', { name: 'Upload image' })).not.toBeInTheDocument()
|
||||
|
||||
rerender(<Harness {...props} onUploadImage={vi.fn()} />)
|
||||
expect(screen.getByRole('button', { name: 'Upload image' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CPLSH-026: picking a file uploads it and clears the input again', async () => {
|
||||
const onUploadImage = vi.fn<(f: File) => Promise<void>>().mockResolvedValue(undefined)
|
||||
setup({ onUploadImage })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Upload image' }))
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
const file = pngFile()
|
||||
fireEvent.change(input, { target: { files: [file] } })
|
||||
await waitFor(() => expect(onUploadImage).toHaveBeenCalledWith(file))
|
||||
expect(input.value).toBe('')
|
||||
})
|
||||
|
||||
it('FE-MOB-CPLSH-027: an empty file pick is a no-op', () => {
|
||||
const onUploadImage = vi.fn<(f: File) => Promise<void>>().mockResolvedValue(undefined)
|
||||
setup({ onUploadImage })
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
fireEvent.change(input, { target: { files: [] } })
|
||||
expect(onUploadImage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-CPLSH-028: a failed upload falls back to the generic upload error', async () => {
|
||||
// No `error` field on the response — the sheet's own copy has to stand in.
|
||||
const onUploadImage = vi.fn<(f: File) => Promise<void>>().mockRejectedValue({ response: { data: {} } })
|
||||
setup({ onUploadImage })
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
fireEvent.change(input, { target: { files: [pngFile()] } })
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Could not upload image', 'error', undefined))
|
||||
})
|
||||
|
||||
it('FE-MOB-CPLSH-029: an existing cover can be dropped again', async () => {
|
||||
const { onSave } = setup({ onUploadImage: vi.fn(), place: place({ image_url: '/uploads/places/n.jpg' }) })
|
||||
expect(screen.getByRole('button', { name: 'Change image' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove image' }))
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ image_url: null }))
|
||||
})
|
||||
|
||||
it('FE-MOB-CPLSH-030: a failed cover removal reports the error', async () => {
|
||||
const onSave = vi.fn<Props['onSave']>().mockRejectedValue({ response: { data: { error: 'Locked' } } })
|
||||
setup({ onUploadImage: vi.fn(), onSave, place: place({ image_url: '/uploads/places/n.jpg' }) })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove image' }))
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Locked', 'error', undefined))
|
||||
})
|
||||
|
||||
it('FE-MOB-CPLSH-031: the ratings row only appears when voting is wired up', () => {
|
||||
const onRate = vi.fn()
|
||||
const { rerender, props } = setup()
|
||||
expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument()
|
||||
|
||||
rerender(<Harness {...props} onRate={onRate} />)
|
||||
expect(screen.getByRole('radiogroup')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('radio', { name: '4' }))
|
||||
expect(onRate).toHaveBeenCalledWith(4)
|
||||
})
|
||||
|
||||
it('FE-MOB-CPLSH-032: opening a different place reseeds the form and leaves edit mode', () => {
|
||||
const { rerender, props } = setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
expect(screen.getByDisplayValue('Narisawa')).toBeInTheDocument()
|
||||
|
||||
rerender(<Harness {...props} place={place({ id: 8, name: 'Den', description: null, links: [], label_ids: [] })} />)
|
||||
expect(screen.queryByDisplayValue('Narisawa')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Den')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('md')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CPLSH-033: closing holds the last place through the exit animation', () => {
|
||||
const { rerender, props } = setup()
|
||||
rerender(<Harness {...props} place={null} />)
|
||||
expect(screen.getByText('Narisawa')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,270 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import type { CollectionMember } from '@trek/shared'
|
||||
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
import MCollShareSheet from '../../../../src/mobile/screens/collections/MCollShareSheet'
|
||||
import { collectionsApi } from '../../../../src/api/collections'
|
||||
import { useCollectionStore } from '../../../../src/store/collectionStore'
|
||||
import { useAuthStore } from '../../../../src/store/authStore'
|
||||
import { buildUser } from '../../../helpers/factories'
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||
import { useTranslation } from '../../../../src/i18n'
|
||||
|
||||
// FE-MOB-CSHARE-001 to FE-MOB-CSHARE-021
|
||||
|
||||
type Props = ComponentProps<typeof MCollShareSheet>
|
||||
|
||||
const OWNER: CollectionMember = { user_id: 1, username: 'maurice', email: 'm@example.com', status: 'accepted', is_owner: true }
|
||||
const EDITOR: CollectionMember = { user_id: 2, username: 'julien', email: 'j@example.com', status: 'accepted', role: 'editor', avatar: 'jul.png' }
|
||||
const VIEWER: CollectionMember = { user_id: 4, username: 'ada', email: 'a@example.com', status: 'accepted', role: 'viewer' }
|
||||
const PENDING: CollectionMember = { user_id: 3, username: 'zoe', email: 'z@example.com', status: 'pending' }
|
||||
|
||||
// The store actions are the seam under test — the sheet only orchestrates them.
|
||||
type CollectionState = ReturnType<typeof useCollectionStore.getState>
|
||||
const initialCollectionState = useCollectionStore.getState()
|
||||
let actions: {
|
||||
invite: Mock<CollectionState['invite']>
|
||||
cancelInvite: Mock<CollectionState['cancelInvite']>
|
||||
removeMember: Mock<CollectionState['removeMember']>
|
||||
setMemberRole: Mock<CollectionState['setMemberRole']>
|
||||
leave: Mock<CollectionState['leave']>
|
||||
}
|
||||
type AddToast = NonNullable<Window['__addToast']>
|
||||
let addToast: Mock<AddToast>
|
||||
|
||||
function Harness(props: Omit<Props, 't'>) {
|
||||
const { t } = useTranslation()
|
||||
return <MCollShareSheet {...props} t={t} />
|
||||
}
|
||||
|
||||
function setup(over: Partial<Props> = {}) {
|
||||
const onClose = vi.fn()
|
||||
const onAfterLeave = vi.fn()
|
||||
const props: Omit<Props, 't'> = {
|
||||
open: true,
|
||||
collectionId: 7,
|
||||
collectionName: 'Tokyo 2026',
|
||||
isOwner: true,
|
||||
members: [PENDING, EDITOR, OWNER],
|
||||
onClose,
|
||||
onAfterLeave,
|
||||
...over,
|
||||
}
|
||||
const view = render(<Harness {...props} />)
|
||||
return { ...view, onClose, onAfterLeave, props }
|
||||
}
|
||||
|
||||
describe('MCollShareSheet', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
useCollectionStore.setState(initialCollectionState, true)
|
||||
actions = {
|
||||
invite: vi.fn<CollectionState['invite']>().mockResolvedValue(undefined),
|
||||
cancelInvite: vi.fn<CollectionState['cancelInvite']>().mockResolvedValue(undefined),
|
||||
removeMember: vi.fn<CollectionState['removeMember']>().mockResolvedValue(undefined),
|
||||
setMemberRole: vi.fn<CollectionState['setMemberRole']>().mockResolvedValue(undefined),
|
||||
leave: vi.fn<CollectionState['leave']>().mockResolvedValue(undefined),
|
||||
}
|
||||
useCollectionStore.setState(actions)
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'maurice' }) })
|
||||
addToast = vi.fn<AddToast>()
|
||||
window.__addToast = addToast
|
||||
vi.spyOn(collectionsApi, 'availableUsers').mockResolvedValue({ users: [{ id: 9, username: 'nina' }, { id: 10, username: 'omar' }] })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
delete window.__addToast
|
||||
})
|
||||
|
||||
it('FE-MOB-CSHARE-001: names the sheet after the list and counts the roster', () => {
|
||||
setup()
|
||||
expect(screen.getByRole('dialog', { name: 'Share list' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Share “Tokyo 2026”')).toBeInTheDocument()
|
||||
expect(screen.getByText('3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CSHARE-002: sorts owner first, then accepted members, then pending invites', () => {
|
||||
setup()
|
||||
const names = screen.getAllByText(/^(maurice|julien|zoe)/).map(n => n.textContent)
|
||||
expect(names).toEqual(['maurice (you)', 'julien', 'zoe'])
|
||||
})
|
||||
|
||||
it('FE-MOB-CSHARE-003: marks the owner, tags the signed-in user and hides a pending email', () => {
|
||||
setup()
|
||||
expect(screen.getByText('OWNER')).toBeInTheDocument()
|
||||
expect(screen.getByText('(you)')).toBeInTheDocument()
|
||||
expect(screen.getByText('j@example.com')).toBeInTheDocument()
|
||||
expect(screen.getByText('PENDING INVITE')).toBeInTheDocument()
|
||||
expect(screen.queryByText('z@example.com')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CSHARE-004: renders the uploaded avatar, otherwise the initial', () => {
|
||||
setup()
|
||||
// The avatar is decorative (alt=""), so it has no accessible role to query by.
|
||||
const avatars = document.querySelectorAll('img')
|
||||
expect(avatars).toHaveLength(1)
|
||||
expect(avatars[0]).toHaveAttribute('src', '/uploads/avatars/jul.png')
|
||||
expect(screen.getByText('M')).toBeInTheDocument()
|
||||
expect(screen.getByText('Z')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CSHARE-005: the owner cycles a member role viewer → editor → admin → viewer', async () => {
|
||||
const { rerender, props } = setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Role' }))
|
||||
await waitFor(() => expect(actions.setMemberRole).toHaveBeenCalledWith(7, 2, 'admin'))
|
||||
|
||||
rerender(<Harness {...props} members={[OWNER, { ...EDITOR, role: 'admin' }]} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Role' }))
|
||||
await waitFor(() => expect(actions.setMemberRole).toHaveBeenCalledWith(7, 2, 'viewer'))
|
||||
|
||||
rerender(<Harness {...props} members={[OWNER, VIEWER]} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Role' }))
|
||||
await waitFor(() => expect(actions.setMemberRole).toHaveBeenCalledWith(7, 4, 'editor'))
|
||||
})
|
||||
|
||||
it('FE-MOB-CSHARE-006: without a collection id the role button is inert', () => {
|
||||
setup({ collectionId: null })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Role' }))
|
||||
expect(actions.setMemberRole).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-CSHARE-007: the owner removes an accepted member and cancels a pending invite', async () => {
|
||||
setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove' }))
|
||||
await waitFor(() => expect(actions.removeMember).toHaveBeenCalledWith(7, 2))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
await waitFor(() => expect(actions.cancelInvite).toHaveBeenCalledWith(7, 3))
|
||||
})
|
||||
|
||||
it('FE-MOB-CSHARE-008: a failing member action surfaces the server message', async () => {
|
||||
actions.removeMember.mockRejectedValue({ response: { data: { error: 'Not allowed' } } })
|
||||
setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove' }))
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Not allowed', 'error', undefined))
|
||||
})
|
||||
|
||||
it('FE-MOB-CSHARE-009: only one member action runs at a time, and the row spins meanwhile', async () => {
|
||||
let resolve!: () => void
|
||||
actions.removeMember.mockReturnValue(new Promise<void>(r => { resolve = r }))
|
||||
setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove' }))
|
||||
expect(screen.getByRole('button', { name: 'Role' })).toBeDisabled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(actions.cancelInvite).not.toHaveBeenCalled()
|
||||
|
||||
resolve()
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Role' })).not.toBeDisabled())
|
||||
})
|
||||
|
||||
it('FE-MOB-CSHARE-010: the owner picks a user and a role, then sends the invite', async () => {
|
||||
setup()
|
||||
expect(await screen.findByRole('button', { name: 'Send invite' })).toBeDisabled()
|
||||
|
||||
fireEvent.click(screen.getByText('Select a user'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'omar' }))
|
||||
expect(screen.queryByRole('button', { name: 'nina' })).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Admin' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send invite' }))
|
||||
|
||||
await waitFor(() => expect(actions.invite).toHaveBeenCalledWith(7, 10, 'admin'))
|
||||
expect(addToast).toHaveBeenCalledWith('Invite sent', 'success', undefined)
|
||||
// The picker falls back to its placeholder for the next invite.
|
||||
await waitFor(() => expect(screen.getByText('Select a user')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('FE-MOB-CSHARE-011: editor is the preselected invite role', async () => {
|
||||
setup()
|
||||
fireEvent.click(await screen.findByText('Select a user'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'nina' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send invite' }))
|
||||
await waitFor(() => expect(actions.invite).toHaveBeenCalledWith(7, 9, 'editor'))
|
||||
})
|
||||
|
||||
it('FE-MOB-CSHARE-012: a failing invite keeps the selection and reports the error', async () => {
|
||||
actions.invite.mockRejectedValue(new Error('boom'))
|
||||
setup()
|
||||
fireEvent.click(await screen.findByText('Select a user'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'nina' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send invite' }))
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Could not send invite', 'error', undefined))
|
||||
expect(screen.getByText('nina')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CSHARE-013: with nobody left to invite the owner is told so', async () => {
|
||||
vi.mocked(collectionsApi.availableUsers).mockResolvedValue({ users: [] })
|
||||
setup()
|
||||
expect(await screen.findByText('No users available to invite.')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Send invite' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CSHARE-014: a failing lookup degrades to the same empty state', async () => {
|
||||
vi.mocked(collectionsApi.availableUsers).mockRejectedValue(new Error('offline'))
|
||||
setup()
|
||||
expect(await screen.findByText('No users available to invite.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CSHARE-015: members never see the invite form, only the read-only roster', async () => {
|
||||
setup({ isOwner: false })
|
||||
expect(screen.getByText('Only the list owner can invite or remove people.')).toBeInTheDocument()
|
||||
expect(screen.getByText('EDITOR')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Role' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Remove' })).not.toBeInTheDocument()
|
||||
await waitFor(() => expect(collectionsApi.availableUsers).not.toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('FE-MOB-CSHARE-016: leaving asks for confirmation first and can be backed out of', () => {
|
||||
setup({ isOwner: false })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Leave list' }))
|
||||
expect(screen.getByText('Leave this shared list? You will lose access until you are invited again.')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(actions.leave).not.toHaveBeenCalled()
|
||||
expect(screen.queryByText('Leave this shared list? You will lose access until you are invited again.')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CSHARE-017: a confirmed leave calls the store and hands back to the screen', async () => {
|
||||
const { onAfterLeave } = setup({ isOwner: false })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Leave list' }))
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /Leave list/ })[0])
|
||||
await waitFor(() => expect(actions.leave).toHaveBeenCalledWith(7))
|
||||
expect(addToast).toHaveBeenCalledWith('You left the list', 'success', undefined)
|
||||
expect(onAfterLeave).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-CSHARE-018: a failing leave reports the error and keeps the user in the list', async () => {
|
||||
actions.leave.mockRejectedValue({ response: { data: { error: 'Owners cannot leave' } } })
|
||||
const { onAfterLeave } = setup({ isOwner: false })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Leave list' }))
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /Leave list/ })[0])
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Owners cannot leave', 'error', undefined))
|
||||
expect(onAfterLeave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-CSHARE-019: without a collection id neither invite nor leave reach the store', async () => {
|
||||
setup({ collectionId: null, isOwner: false })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Leave list' }))
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /Leave list/ })[0])
|
||||
await waitFor(() => expect(actions.leave).not.toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('FE-MOB-CSHARE-020: closing resets the picker and the leave confirmation', async () => {
|
||||
const { rerender, props, onClose } = setup()
|
||||
fireEvent.click(await screen.findByText('Select a user'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'nina' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
|
||||
rerender(<Harness {...props} open={false} />)
|
||||
rerender(<Harness {...props} open />)
|
||||
expect(await screen.findByText('Select a user')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CSHARE-021: a closed sheet renders nothing and asks for no users', () => {
|
||||
setup({ open: false })
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(collectionsApi.availableUsers).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
// FE-MOB-COLKIT-001 to FE-MOB-COLKIT-006
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '../../../helpers/render'
|
||||
import {
|
||||
CancelPill, Eyebrow, INPUT_CLS, PrimaryPill, SheetFooter, SheetHeader, TEXTAREA_CLS,
|
||||
} from '../../../../src/mobile/screens/collections/MCollSheetKit'
|
||||
|
||||
describe('MCollSheetKit', () => {
|
||||
it('FE-MOB-COLKIT-001: the sheet header shows the title and closes through its labelled button', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<SheetHeader title="Edit list" onClose={onClose} closeLabel="Close" />)
|
||||
|
||||
expect(screen.getByText('Edit list')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-COLKIT-002: the eyebrow renders its children and merges an extra class', () => {
|
||||
const { container } = render(<Eyebrow className="mt-4">Cover</Eyebrow>)
|
||||
|
||||
const el = container.firstElementChild as HTMLElement
|
||||
expect(el).toHaveTextContent('Cover')
|
||||
expect(el.className).toContain('mt-4')
|
||||
expect(el.className).toContain('text-m-faint')
|
||||
})
|
||||
|
||||
it('FE-MOB-COLKIT-003: the cancel pill fires onClick and honours disabled', () => {
|
||||
const onClick = vi.fn()
|
||||
const { rerender } = render(<CancelPill onClick={onClick}>Cancel</CancelPill>)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
|
||||
rerender(<CancelPill onClick={onClick} disabled>Cancel</CancelPill>)
|
||||
const btn = screen.getByRole('button', { name: 'Cancel' })
|
||||
expect(btn).toBeDisabled()
|
||||
fireEvent.click(btn)
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-COLKIT-004: the primary pill fires onClick, honours disabled and keeps the accent surface', () => {
|
||||
const onClick = vi.fn()
|
||||
const { rerender } = render(<PrimaryPill onClick={onClick} className="!bg-red-500">Save</PrimaryPill>)
|
||||
|
||||
const btn = screen.getByRole('button', { name: 'Save' })
|
||||
expect(btn.className).toContain('bg-m-act')
|
||||
expect(btn.className).toContain('!bg-red-500')
|
||||
fireEvent.click(btn)
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
|
||||
rerender(<PrimaryPill onClick={onClick} disabled>Save</PrimaryPill>)
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-COLKIT-005: a pill without onClick stays inert on click', () => {
|
||||
render(<PrimaryPill>Add</PrimaryPill>)
|
||||
// No handler wired — clicking must not throw.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
expect(screen.getByRole('button', { name: 'Add' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-COLKIT-006: the footer renders its children and the field classes use mobile tokens', () => {
|
||||
render(<SheetFooter><span>footer slot</span></SheetFooter>)
|
||||
expect(screen.getByText('footer slot')).toBeInTheDocument()
|
||||
|
||||
// Both field presets have to sit on the sheet surface token, not a raw colour.
|
||||
expect(INPUT_CLS).toContain('bg-[color:var(--m-sheet)]')
|
||||
expect(TEXTAREA_CLS).toContain('bg-[color:var(--m-sheet)]')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,169 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
import MCollTripPickerSheet from '../../../../src/mobile/screens/collections/MCollTripPickerSheet'
|
||||
import { tripsApi } from '../../../../src/api/client'
|
||||
import { useTranslation } from '../../../../src/i18n'
|
||||
|
||||
// FE-MOB-CTRIPP-001 to FE-MOB-CTRIPP-014
|
||||
|
||||
type Props = ComponentProps<typeof MCollTripPickerSheet>
|
||||
type CopyResult = { copied: number; skipped: { id: number; name: string }[] }
|
||||
|
||||
const TRIPS = [
|
||||
{ id: 1, title: 'Japan 2020', start_date: '2020-03-01', end_date: '2020-03-05', cover_image: '/uploads/covers/jp.jpg' },
|
||||
{ id: 2, title: 'Weekend in Prague', start_date: null, end_date: null, cover_image: null },
|
||||
{ id: 3, title: 'Open ended', start_date: '2020-03-01', end_date: null, cover_image: null },
|
||||
]
|
||||
|
||||
function Harness(props: Omit<Props, 't'>) {
|
||||
const { t } = useTranslation()
|
||||
return <MCollTripPickerSheet {...props} t={t} />
|
||||
}
|
||||
|
||||
type AddToast = NonNullable<Window['__addToast']>
|
||||
let addToast: Mock<AddToast>
|
||||
|
||||
function setup(over: Partial<Props> = {}) {
|
||||
const onCopy = vi.fn<(id: number) => Promise<CopyResult>>().mockResolvedValue({ copied: 1, skipped: [] })
|
||||
const onClose = vi.fn()
|
||||
const props = { open: true, count: 1, onCopy, onClose, ...over }
|
||||
const view = render(<Harness {...props} />)
|
||||
return { ...view, onCopy, onClose, props }
|
||||
}
|
||||
|
||||
describe('MCollTripPickerSheet', () => {
|
||||
beforeEach(() => {
|
||||
addToast = vi.fn<AddToast>()
|
||||
window.__addToast = addToast
|
||||
vi.spyOn(tripsApi, 'list').mockResolvedValue({ trips: TRIPS })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
delete window.__addToast
|
||||
})
|
||||
|
||||
it('FE-MOB-CTRIPP-001: a single place uses the plain title, several the counted one', async () => {
|
||||
const { rerender, props } = setup()
|
||||
expect(await screen.findByText('Japan 2020')).toBeInTheDocument()
|
||||
expect(screen.getByRole('dialog', { name: 'Copy to trip' })).toBeInTheDocument()
|
||||
|
||||
rerender(<Harness {...props} count={4} />)
|
||||
expect(screen.getByRole('dialog', { name: 'Copy 4 to trip' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CTRIPP-002: renders every trip with its cover and date range', async () => {
|
||||
setup()
|
||||
const row = await screen.findByRole('button', { name: /Japan 2020/ })
|
||||
expect(row.querySelector('img')).toHaveAttribute('src', '/uploads/covers/jp.jpg')
|
||||
expect(screen.getByText('Sun, Mar 1, 2020 – Thu, Mar 5, 2020')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CTRIPP-003: a trip without dates shows no range, an open-ended one only its start', async () => {
|
||||
setup()
|
||||
const noDates = await screen.findByRole('button', { name: /Weekend in Prague/ })
|
||||
expect(noDates.querySelector('.lucide-calendar-days')).toBeNull()
|
||||
// Without a cover the pin placeholder stands in.
|
||||
expect(noDates.querySelector('.lucide-map-pin')).not.toBeNull()
|
||||
expect(screen.getByText('Sun, Mar 1, 2020')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CTRIPP-004: the spinner holds the list until the trips arrive', async () => {
|
||||
let resolve!: (v: { trips: typeof TRIPS }) => void
|
||||
vi.mocked(tripsApi.list).mockReturnValue(new Promise(r => { resolve = r }))
|
||||
setup()
|
||||
expect(document.querySelector('.animate-spin')).not.toBeNull()
|
||||
expect(screen.queryByText('No trips yet')).not.toBeInTheDocument()
|
||||
|
||||
resolve({ trips: TRIPS })
|
||||
expect(await screen.findByText('Japan 2020')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CTRIPP-005: a response without a trips array falls back to the empty state', async () => {
|
||||
vi.mocked(tripsApi.list).mockResolvedValue({})
|
||||
setup()
|
||||
expect(await screen.findByText('No trips yet')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CTRIPP-006: a failing request shows the empty state instead of breaking', async () => {
|
||||
vi.mocked(tripsApi.list).mockRejectedValue(new Error('offline'))
|
||||
setup()
|
||||
expect(await screen.findByText('No trips yet')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CTRIPP-007: a closed sheet asks for nothing and renders nothing', () => {
|
||||
setup({ open: false })
|
||||
expect(tripsApi.list).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CTRIPP-008: the search box filters the rows by title, case-insensitively', async () => {
|
||||
setup()
|
||||
await screen.findByText('Japan 2020')
|
||||
fireEvent.change(screen.getByPlaceholderText('Search trips'), { target: { value: 'prague' } })
|
||||
expect(screen.getByText('Weekend in Prague')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Japan 2020')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search trips'), { target: { value: 'nothing' } })
|
||||
expect(screen.getByText('No trips yet')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CTRIPP-009: copying reports the count and closes the sheet', async () => {
|
||||
const { onCopy, onClose } = setup()
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Japan 2020/ }))
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled())
|
||||
expect(onCopy).toHaveBeenCalledWith(1)
|
||||
expect(addToast).toHaveBeenCalledWith('Copied 1 places', 'success', undefined)
|
||||
})
|
||||
|
||||
it('FE-MOB-CTRIPP-010: duplicates the server skipped get their own toast', async () => {
|
||||
const onCopy = vi.fn<(id: number) => Promise<CopyResult>>()
|
||||
.mockResolvedValue({ copied: 2, skipped: [{ id: 9, name: 'Louvre' }] })
|
||||
setup({ onCopy })
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Japan 2020/ }))
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Skipped 1 duplicates', 'info', undefined))
|
||||
expect(addToast).toHaveBeenCalledWith('Copied 2 places', 'success', undefined)
|
||||
})
|
||||
|
||||
it('FE-MOB-CTRIPP-011: a no-op copy says so rather than staying silent', async () => {
|
||||
const onCopy = vi.fn<(id: number) => Promise<CopyResult>>().mockResolvedValue({ copied: 0, skipped: [] })
|
||||
setup({ onCopy })
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Japan 2020/ }))
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Nothing to copy', 'info', undefined))
|
||||
})
|
||||
|
||||
it('FE-MOB-CTRIPP-012: a failed copy surfaces the server message and keeps the sheet open', async () => {
|
||||
const onCopy = vi.fn<(id: number) => Promise<CopyResult>>()
|
||||
.mockRejectedValue({ response: { data: { error: 'Trip is locked' } } })
|
||||
const { onClose } = setup({ onCopy })
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Japan 2020/ }))
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Trip is locked', 'error', undefined))
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-CTRIPP-013: while a copy runs the row spins and a second trip is ignored', async () => {
|
||||
let resolve!: (v: CopyResult) => void
|
||||
const onCopy = vi.fn<(id: number) => Promise<CopyResult>>()
|
||||
.mockReturnValue(new Promise<CopyResult>(r => { resolve = r }))
|
||||
const { onClose } = setup({ onCopy })
|
||||
const row = await screen.findByRole('button', { name: /Japan 2020/ })
|
||||
fireEvent.click(row)
|
||||
expect(row).toBeDisabled()
|
||||
expect(row.querySelector('.animate-spin')).not.toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Weekend in Prague/ }))
|
||||
expect(onCopy).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolve({ copied: 1, skipped: [] })
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('FE-MOB-CTRIPP-014: the header close hands back without copying', async () => {
|
||||
const { onClose, onCopy } = setup()
|
||||
await screen.findByText('Japan 2020')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
expect(onCopy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,618 @@
|
||||
// FE-MOB-COLSCR-001 to FE-MOB-COLSCR-031
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '../../../helpers/render'
|
||||
import MCollections from '../../../../src/mobile/screens/collections/MCollections'
|
||||
import { ALL_SAVED } from '../../../../src/store/collectionStore'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ coll: {} as Record<string, unknown> }))
|
||||
|
||||
vi.mock('../../../../src/pages/collections/useCollections', () => ({
|
||||
useCollections: () => mocks.coll,
|
||||
}))
|
||||
|
||||
// Keep the Leaflet/MapLibre stack out of jsdom, but keep its contract.
|
||||
vi.mock('../../../../src/components/Collections/CollectionMap', () => ({
|
||||
default: (props: { places: { id: number }[]; onOpenPlace: (id: number) => void; onDeselect: () => void; dark: boolean }) => (
|
||||
<div data-testid="map">
|
||||
<span>{`markers:${props.places.length}`}</span>
|
||||
<span>{`dark:${String(props.dark)}`}</span>
|
||||
<button type="button" onClick={() => props.onOpenPlace(props.places[0].id)}>map-open</button>
|
||||
<button type="button" onClick={props.onDeselect}>map-deselect</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/mobile/screens/collections/MCollPlaceSheet', () => ({
|
||||
default: (props: {
|
||||
place: { id: number } | null
|
||||
onSave: (patch: Record<string, unknown>) => void
|
||||
onUploadImage: (file: File) => void
|
||||
onRate: (rating: number) => void
|
||||
onCopyToTrip: () => void
|
||||
onRemove: () => void
|
||||
onSetStatus: (status: string) => void
|
||||
onClose: () => void
|
||||
}) =>
|
||||
props.place ? (
|
||||
<div>
|
||||
<button type="button" onClick={() => props.onSave({ notes: 'moin' })}>place-save</button>
|
||||
<button type="button" onClick={() => props.onUploadImage(new File(['x'], 'p.png'))}>place-upload</button>
|
||||
<button type="button" onClick={() => props.onRate(4)}>place-rate</button>
|
||||
<button type="button" onClick={props.onCopyToTrip}>place-copy</button>
|
||||
<button type="button" onClick={props.onRemove}>place-remove</button>
|
||||
<button type="button" onClick={() => props.onSetStatus('visited')}>place-status</button>
|
||||
<button type="button" onClick={props.onClose}>place-close</button>
|
||||
</div>
|
||||
) : null,
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/mobile/screens/collections/MCollAddSheet', () => ({
|
||||
default: (props: { open: boolean; collectionId: number | null; collectionName: string; lists: { id: number }[]; onClose: () => void; onAdded: () => void }) =>
|
||||
props.open ? (
|
||||
<div>
|
||||
<span>{`add-target:${String(props.collectionId)}:${props.collectionName}:${props.lists.length}`}</span>
|
||||
<button type="button" onClick={props.onClose}>add-close</button>
|
||||
<button type="button" onClick={props.onAdded}>add-added</button>
|
||||
</div>
|
||||
) : null,
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/mobile/screens/collections/MCollShareSheet', () => ({
|
||||
default: (props: { open: boolean; collectionId: number | null; onClose: () => void; onAfterLeave: () => void }) =>
|
||||
props.open ? (
|
||||
<div>
|
||||
<span>{`share:${String(props.collectionId)}`}</span>
|
||||
<button type="button" onClick={props.onClose}>share-close</button>
|
||||
<button type="button" onClick={props.onAfterLeave}>share-left</button>
|
||||
</div>
|
||||
) : null,
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/mobile/screens/collections/MCollEditSheet', () => ({
|
||||
default: (props: { target: unknown; onClose: () => void; onCreated: (id: number) => void; onRequestDelete: (id: number) => void }) =>
|
||||
props.target ? (
|
||||
<div>
|
||||
<span>{`editor:${typeof props.target === 'string' ? props.target : 'list'}`}</span>
|
||||
<button type="button" onClick={props.onClose}>editor-close</button>
|
||||
<button type="button" onClick={() => props.onCreated(77)}>editor-created</button>
|
||||
<button type="button" onClick={() => props.onRequestDelete(3)}>editor-delete</button>
|
||||
</div>
|
||||
) : null,
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/mobile/screens/collections/MCollLabelsSheet', () => ({
|
||||
default: (props: {
|
||||
open: boolean
|
||||
mode: string
|
||||
selectedCount: number
|
||||
onCreate: (name: string) => void
|
||||
onUpdate: (id: number, body: Record<string, unknown>) => void
|
||||
onDelete: (id: number) => void
|
||||
onAssign: (ids: number[]) => void
|
||||
onSwitchToManage: () => void
|
||||
onClose: () => void
|
||||
}) =>
|
||||
props.open ? (
|
||||
<div>
|
||||
<span>{`labels:${props.mode}:${props.selectedCount}`}</span>
|
||||
<button type="button" onClick={() => props.onCreate('Food')}>labels-create</button>
|
||||
<button type="button" onClick={() => props.onUpdate(1, { name: 'Drinks' })}>labels-update</button>
|
||||
<button type="button" onClick={() => props.onDelete(1)}>labels-delete</button>
|
||||
<button type="button" onClick={() => props.onAssign([1])}>labels-assign</button>
|
||||
<button type="button" onClick={props.onSwitchToManage}>labels-manage</button>
|
||||
<button type="button" onClick={props.onClose}>labels-close</button>
|
||||
</div>
|
||||
) : null,
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/mobile/screens/collections/MCollTripPickerSheet', () => ({
|
||||
default: (props: { open: boolean; count: number; onCopy: (id: number) => void; onClose: () => void }) =>
|
||||
props.open ? (
|
||||
<div>
|
||||
<span>{`trippicker:${props.count}`}</span>
|
||||
<button type="button" onClick={() => props.onCopy(9)}>trip-copy</button>
|
||||
<button type="button" onClick={props.onClose}>trip-close</button>
|
||||
</div>
|
||||
) : null,
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/mobile/screens/collections/MCollListPickerSheet', () => ({
|
||||
default: (props: { mode: string | null; lists: { id: number; name: string }[]; count: number; onPick: (id: number) => void; onClose: () => void }) =>
|
||||
props.mode ? (
|
||||
<div>
|
||||
<span>{`listpicker:${props.mode}:${props.count}:${props.lists.map(l => l.id).join(',')}`}</span>
|
||||
<button type="button" onClick={() => props.onPick(2)}>listpicker-pick</button>
|
||||
<button type="button" onClick={props.onClose}>listpicker-close</button>
|
||||
</div>
|
||||
) : null,
|
||||
}))
|
||||
|
||||
const list = (id: number, name: string, over: Record<string, unknown> = {}) => ({
|
||||
id, owner_id: 1, name, color: '#38BDF8', place_count: 2, is_owner: true, ...over,
|
||||
})
|
||||
|
||||
const place = (id: number, name: string, over: Record<string, unknown> = {}) => ({
|
||||
id, collection_id: 1, name, address: `${name} 1`, status: 'idea', ...over,
|
||||
})
|
||||
|
||||
function makeHook(over: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
const noop = vi.fn()
|
||||
return {
|
||||
t: (key: string) => key,
|
||||
language: 'en',
|
||||
dark: false,
|
||||
navigate: noop,
|
||||
categories: [],
|
||||
collections: [list(1, 'Hamburg')],
|
||||
ownedLists: [list(1, 'Hamburg')],
|
||||
sharedLists: [],
|
||||
activeCollection: list(1, 'Hamburg'),
|
||||
isAllSaved: false,
|
||||
isOwner: true,
|
||||
canEdit: true,
|
||||
canDelete: true,
|
||||
canShare: true,
|
||||
activeId: 1,
|
||||
places: [place(10, 'Elbphilharmonie')],
|
||||
visiblePlaces: [place(10, 'Elbphilharmonie')],
|
||||
mappable: [place(10, 'Elbphilharmonie', { lat: 53.5, lng: 10 })],
|
||||
members: [],
|
||||
incomingInvites: [],
|
||||
counts: { all: 1, idea: 1, want: 0, visited: 0 },
|
||||
view: 'list',
|
||||
statusFilter: 'all',
|
||||
categoryFilter: 'all',
|
||||
ratingFilter: 'all',
|
||||
search: '',
|
||||
selectedPlaceId: null,
|
||||
selectMode: false,
|
||||
selectedIds: [],
|
||||
labels: [],
|
||||
labelFilter: [],
|
||||
labelOptions: [],
|
||||
loading: false,
|
||||
placesLoading: false,
|
||||
setView: vi.fn(), setStatusFilter: vi.fn(), setCategoryFilter: vi.fn(), setRatingFilter: vi.fn(),
|
||||
setLabelFilter: vi.fn(), setSearch: vi.fn(), setSelectedPlaceId: vi.fn(), setSelectMode: vi.fn(),
|
||||
toggleSelect: vi.fn(), updatePlace: vi.fn(), uploadPlaceImage: vi.fn(),
|
||||
showLabelManager: false, setShowLabelManager: vi.fn(), labelPickerOpen: false, setLabelPickerOpen: vi.fn(),
|
||||
handleCreateLabel: vi.fn(), handleUpdateLabel: vi.fn(), handleDeleteLabel: vi.fn(), handleBulkAssignLabels: vi.fn(),
|
||||
editorTarget: null, setEditorTarget: vi.fn(), handleEditorCreated: vi.fn(),
|
||||
showAddPlace: false, setShowAddPlace: vi.fn(), handlePlaceAdded: vi.fn(),
|
||||
confirmDeleteList: null, setConfirmDeleteList: vi.fn(),
|
||||
showShare: false, setShowShare: vi.fn(), handleAfterLeave: vi.fn(),
|
||||
selectedPlace: null, handleCloseDetail: vi.fn(), handleDetailStatus: vi.fn(), handleDetailRemove: vi.fn(),
|
||||
copyIds: null, openCopyForSelectedPlace: vi.fn(), openCopyForSelection: vi.fn(), closeCopy: vi.fn(), handleCopyToTrip: vi.fn(),
|
||||
handleSelectList: vi.fn(), handleDeleteList: vi.fn(),
|
||||
handleStatusChange: vi.fn(), handleRatePlace: vi.fn(), handleDeleteSelected: vi.fn(),
|
||||
allVisibleSelected: false, handleSelectAll: vi.fn(),
|
||||
listPickerMode: null, setListPickerMode: vi.fn(), handleMoveToList: vi.fn(), handleDuplicateToList: vi.fn(),
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
const fn = (key: string) => mocks.coll[key] as ReturnType<typeof vi.fn>
|
||||
|
||||
/** The list switcher is the first control in the header. */
|
||||
const switcher = () => screen.getAllByRole('button')[0]
|
||||
|
||||
describe('MCollections', () => {
|
||||
beforeEach(() => {
|
||||
mocks.coll = makeHook()
|
||||
})
|
||||
|
||||
it('FE-MOB-COLSCR-001: shows only the spinner while the first load is in flight', () => {
|
||||
mocks.coll = makeHook({ loading: true, collections: [], places: [], visiblePlaces: [] })
|
||||
render(<MCollections />)
|
||||
|
||||
expect(screen.queryByPlaceholderText('collections.search')).not.toBeInTheDocument()
|
||||
expect(document.querySelector('.animate-spin')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-COLSCR-002: with no lists at all it offers the first-list CTA', () => {
|
||||
mocks.coll = makeHook({ collections: [], ownedLists: [], activeCollection: null, activeId: null, canShare: false })
|
||||
render(<MCollections />)
|
||||
|
||||
expect(screen.getByText('collections.empty.firstTitle')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: /collections.newList/ }))
|
||||
expect(fn('setEditorTarget')).toHaveBeenCalledWith('new')
|
||||
expect(screen.queryByPlaceholderText('collections.search')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-COLSCR-003: the header shows the active list and its edit/share actions', () => {
|
||||
render(<MCollections />)
|
||||
|
||||
expect(screen.getByText('Hamburg')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collections.editListTitle' }))
|
||||
expect(fn('setEditorTarget')).toHaveBeenCalledWith(expect.objectContaining({ id: 1 }))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collections.share.title' }))
|
||||
expect(fn('setShowShare')).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('FE-MOB-COLSCR-004: the "All saved" union has no edit action and its own title', () => {
|
||||
mocks.coll = makeHook({ isAllSaved: true, activeId: ALL_SAVED, activeCollection: null, canShare: false })
|
||||
render(<MCollections />)
|
||||
|
||||
expect(screen.getByText('collections.allSaved')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'collections.editListTitle' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'collections.share.title' })).not.toBeInTheDocument()
|
||||
// No per-list labels on the union.
|
||||
expect(screen.queryByRole('button', { name: /collections.linkLabel/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-COLSCR-005: without an active list the generic page title is used', () => {
|
||||
mocks.coll = makeHook({ activeId: null, activeCollection: null, canShare: false })
|
||||
render(<MCollections />)
|
||||
expect(screen.getByText('collections.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-COLSCR-006: the switcher dropdown lists owned + shared lists and opens the editor', () => {
|
||||
mocks.coll = makeHook({
|
||||
collections: [list(1, 'Hamburg'), list(2, 'Julien Tipps', { is_owner: false, color: null })],
|
||||
ownedLists: [list(1, 'Hamburg')],
|
||||
sharedLists: [list(2, 'Julien Tipps', { is_owner: false, color: null, place_count: undefined })],
|
||||
})
|
||||
render(<MCollections />)
|
||||
|
||||
fireEvent.click(switcher())
|
||||
expect(screen.getByText('Julien Tipps')).toBeInTheDocument()
|
||||
expect(screen.getByText('0')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Julien Tipps'))
|
||||
expect(fn('handleSelectList')).toHaveBeenCalledWith(2)
|
||||
// Picking closes the dropdown again.
|
||||
expect(screen.queryByText('Julien Tipps')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-COLSCR-007: the dropdown switches to the union and creates a new list', () => {
|
||||
render(<MCollections />)
|
||||
|
||||
fireEvent.click(switcher())
|
||||
fireEvent.click(screen.getByRole('button', { name: /collections.allSaved/ }))
|
||||
expect(fn('handleSelectList')).toHaveBeenCalledWith(ALL_SAVED)
|
||||
|
||||
fireEvent.click(switcher())
|
||||
fireEvent.click(screen.getByRole('button', { name: /collections.newList/ }))
|
||||
expect(fn('setEditorTarget')).toHaveBeenCalledWith('new')
|
||||
})
|
||||
|
||||
it('FE-MOB-COLSCR-008: the view toggle and the search box drive the shared state', () => {
|
||||
render(<MCollections />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collections.view.map' }))
|
||||
expect(fn('setView')).toHaveBeenCalledWith('map')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collections.view.list' }))
|
||||
expect(fn('setView')).toHaveBeenCalledWith('list')
|
||||
expect(screen.getByRole('button', { name: 'collections.view.list' })).toHaveAttribute('aria-pressed', 'true')
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('collections.search'), { target: { value: 'elb' } })
|
||||
expect(fn('setSearch')).toHaveBeenCalledWith('elb')
|
||||
})
|
||||
|
||||
it('FE-MOB-COLSCR-009: the status filter dropdown shows the counts and applies a status', () => {
|
||||
// The union has no label chip, so the status chip is the only "all" chip.
|
||||
mocks.coll = makeHook({
|
||||
isAllSaved: true, activeId: ALL_SAVED, activeCollection: null, canShare: false,
|
||||
counts: { all: 5, idea: 2, want: 2, visited: 1 },
|
||||
})
|
||||
render(<MCollections />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collections.status.filterAll' }))
|
||||
expect(screen.getByText('5')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: /collections.status.want/ }))
|
||||
expect(fn('setStatusFilter')).toHaveBeenCalledWith('want')
|
||||
expect(screen.queryByText('5')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-COLSCR-010: an active status filter is reflected in the chip label', () => {
|
||||
mocks.coll = makeHook({
|
||||
isAllSaved: true, activeId: ALL_SAVED, activeCollection: null, canShare: false, statusFilter: 'visited',
|
||||
})
|
||||
render(<MCollections />)
|
||||
|
||||
const chip = screen.getByRole('button', { name: 'collections.status.visited' })
|
||||
expect(chip.className).toContain('bg-m-act')
|
||||
fireEvent.click(chip)
|
||||
fireEvent.click(screen.getByRole('button', { name: /collections.status.filterAll/ }))
|
||||
expect(fn('setStatusFilter')).toHaveBeenCalledWith('all')
|
||||
})
|
||||
|
||||
it('FE-MOB-COLSCR-011: several active labels collapse to their count and can be toggled off or cleared', () => {
|
||||
mocks.coll = makeHook({
|
||||
labelOptions: [
|
||||
{ id: 1, collection_id: 1, name: 'Food', color: '#f00', count: 2 },
|
||||
{ id: 2, collection_id: 1, name: 'Bar', color: null, count: 1 },
|
||||
],
|
||||
labelFilter: [1, 2],
|
||||
statusFilter: 'want',
|
||||
})
|
||||
render(<MCollections />)
|
||||
|
||||
// Two active labels → the chip shows how many, not their names.
|
||||
fireEvent.click(screen.getByRole('button', { name: '2' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Food\s*2$/ }))
|
||||
expect(fn('setLabelFilter')).toHaveBeenCalledWith([2])
|
||||
|
||||
// The "all" row wipes the filter and closes the panel.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collections.status.filterAll' }))
|
||||
expect(fn('setLabelFilter')).toHaveBeenLastCalledWith([])
|
||||
expect(screen.queryByRole('button', { name: /^Food\s*2$/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-COLSCR-012: a single active label shows its name and another one is added to the filter', () => {
|
||||
mocks.coll = makeHook({
|
||||
labelOptions: [
|
||||
{ id: 1, collection_id: 1, name: 'Food', color: null, count: 2 },
|
||||
{ id: 2, collection_id: 1, name: 'Bar', color: '#0f0', count: 1 },
|
||||
],
|
||||
labelFilter: [2],
|
||||
})
|
||||
render(<MCollections />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Bar' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Food\s*2$/ }))
|
||||
expect(fn('setLabelFilter')).toHaveBeenCalledWith([2, 1])
|
||||
})
|
||||
|
||||
it('FE-MOB-COLSCR-013: a list without labels says so, and the manage chip opens the manager', () => {
|
||||
render(<MCollections />)
|
||||
|
||||
// Status chip and label chip both read "all" while nothing is filtered.
|
||||
const chips = screen.getAllByRole('button', { name: 'collections.status.filterAll' })
|
||||
expect(chips).toHaveLength(2)
|
||||
fireEvent.click(chips[1])
|
||||
expect(screen.getByText('collections.labels.empty')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /collections.linkLabel/ }))
|
||||
expect(fn('setShowLabelManager')).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('FE-MOB-COLSCR-014: the backdrop under an open dropdown closes it again', () => {
|
||||
mocks.coll = makeHook({ isAllSaved: true, activeId: ALL_SAVED, activeCollection: null, canShare: false })
|
||||
render(<MCollections />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collections.status.filterAll' }))
|
||||
// Chip plus the "all" row of the open panel.
|
||||
expect(screen.getAllByRole('button', { name: /collections.status.filterAll/ })).toHaveLength(2)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.close' }))
|
||||
expect(screen.getAllByRole('button', { name: /collections.status.filterAll/ })).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-COLSCR-031: the select chip enters select mode', () => {
|
||||
render(<MCollections />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /collections.select$/ }))
|
||||
expect(fn('setSelectMode')).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('FE-MOB-COLSCR-015: select mode exposes the bulk actions and disables them without a selection', () => {
|
||||
mocks.coll = makeHook({ selectMode: true, selectedIds: [] })
|
||||
render(<MCollections />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /collections.copyToTrip/ })).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: /collections.selectAll/ }))
|
||||
expect(fn('handleSelectAll')).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
|
||||
expect(fn('setSelectMode')).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('FE-MOB-COLSCR-016: with a selection every bulk action reaches its handler', () => {
|
||||
mocks.coll = makeHook({
|
||||
selectMode: true,
|
||||
selectedIds: [10, 11],
|
||||
allVisibleSelected: true,
|
||||
labelOptions: [{ id: 1, collection_id: 1, name: 'Food', count: 1 }],
|
||||
})
|
||||
render(<MCollections />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /collections.deselectAll/ })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: /collections.labels.assign/ }))
|
||||
expect(fn('setLabelPickerOpen')).toHaveBeenCalledWith(true)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /collections.copyToTrip/ }))
|
||||
expect(fn('openCopyForSelection')).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /collections.moveToList/ }))
|
||||
expect(fn('setListPickerMode')).toHaveBeenCalledWith('move')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /collections.duplicateToList/ }))
|
||||
expect(fn('setListPickerMode')).toHaveBeenCalledWith('copy')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /common.delete/ }))
|
||||
expect(fn('handleDeleteSelected')).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-COLSCR-017: a viewer without edit/delete rights loses move and delete', () => {
|
||||
mocks.coll = makeHook({ selectMode: true, selectedIds: [10], canEdit: false, canDelete: false, isOwner: false })
|
||||
render(<MCollections />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: /collections.moveToList/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /common.delete/ })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /collections.duplicateToList/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-COLSCR-018: the list view renders rows, the loading state and both empty states', () => {
|
||||
render(<MCollections />)
|
||||
expect(screen.getByText('Elbphilharmonie')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('Elbphilharmonie'))
|
||||
expect(fn('setSelectedPlaceId')).toHaveBeenCalledWith(10)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collections.status.idea' }))
|
||||
expect(fn('handleStatusChange')).toHaveBeenCalledWith(10, 'want')
|
||||
|
||||
mocks.coll = makeHook({ places: [], visiblePlaces: [], placesLoading: true })
|
||||
const loadingView = render(<MCollections />)
|
||||
expect(loadingView.container.querySelector('.animate-spin')).not.toBeNull()
|
||||
|
||||
mocks.coll = makeHook({ places: [], visiblePlaces: [] })
|
||||
render(<MCollections />)
|
||||
expect(screen.getByText('collections.empty.title')).toBeInTheDocument()
|
||||
|
||||
mocks.coll = makeHook({ visiblePlaces: [] })
|
||||
render(<MCollections />)
|
||||
expect(screen.getByText('collections.empty.noMatchTitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-COLSCR-019: the map view hands the mappable places to the map and reports clicks', () => {
|
||||
mocks.coll = makeHook({ view: 'map', dark: true })
|
||||
render(<MCollections />)
|
||||
|
||||
expect(screen.getByText('markers:1')).toBeInTheDocument()
|
||||
expect(screen.getByText('dark:true')).toBeInTheDocument()
|
||||
// No status/label chips in map view.
|
||||
expect(screen.queryByRole('button', { name: /collections.status.filterAll/ })).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('map-open'))
|
||||
expect(fn('setSelectedPlaceId')).toHaveBeenCalledWith(10)
|
||||
fireEvent.click(screen.getByText('map-deselect'))
|
||||
expect(fn('setSelectedPlaceId')).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-MOB-COLSCR-020: a map view with nothing mappable falls back to the empty note', () => {
|
||||
mocks.coll = makeHook({ view: 'map', mappable: [] })
|
||||
render(<MCollections />)
|
||||
|
||||
expect(screen.queryByTestId('map')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('collections.empty.noMatchTitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-COLSCR-021: the place, add, share, label, trip and list sheets are wired to the hook', () => {
|
||||
mocks.coll = makeHook({
|
||||
selectedPlace: place(10, 'Elbphilharmonie'),
|
||||
selectedPlaceId: 10,
|
||||
showAddPlace: true,
|
||||
showShare: true,
|
||||
labelPickerOpen: true,
|
||||
selectedIds: [10, 11],
|
||||
copyIds: [10],
|
||||
listPickerMode: 'move',
|
||||
ownedLists: [list(1, 'Hamburg'), list(2, 'Kopenhagen')],
|
||||
})
|
||||
render(<MCollections />)
|
||||
|
||||
fireEvent.click(screen.getByText('place-save'))
|
||||
expect(fn('updatePlace')).toHaveBeenCalledWith(10, { notes: 'moin' })
|
||||
fireEvent.click(screen.getByText('place-upload'))
|
||||
expect(fn('uploadPlaceImage')).toHaveBeenCalledWith(10, expect.any(File))
|
||||
fireEvent.click(screen.getByText('place-rate'))
|
||||
expect(fn('handleRatePlace')).toHaveBeenCalledWith(10, 4)
|
||||
fireEvent.click(screen.getByText('place-copy'))
|
||||
expect(fn('openCopyForSelectedPlace')).toHaveBeenCalledTimes(1)
|
||||
|
||||
// The add sheet gets the active list as its fixed target.
|
||||
expect(screen.getByText('add-target:1:Hamburg:2')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('add-close'))
|
||||
expect(fn('setShowAddPlace')).toHaveBeenCalledWith(false)
|
||||
|
||||
expect(screen.getByText('share:1')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('share-close'))
|
||||
expect(fn('setShowShare')).toHaveBeenCalledWith(false)
|
||||
|
||||
// The picker is in assign mode while the label picker is open.
|
||||
expect(screen.getByText('labels:assign:2')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('labels-manage'))
|
||||
expect(fn('setLabelPickerOpen')).toHaveBeenCalledWith(false)
|
||||
expect(fn('setShowLabelManager')).toHaveBeenCalledWith(true)
|
||||
fireEvent.click(screen.getByText('labels-close'))
|
||||
expect(fn('setShowLabelManager')).toHaveBeenCalledWith(false)
|
||||
|
||||
expect(screen.getByText('trippicker:1')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('trip-copy'))
|
||||
expect(fn('handleCopyToTrip')).toHaveBeenCalledWith(9)
|
||||
|
||||
// The active list is not offered as a move target.
|
||||
expect(screen.getByText('listpicker:move:2:2')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('listpicker-pick'))
|
||||
expect(fn('handleMoveToList')).toHaveBeenCalledWith(2)
|
||||
fireEvent.click(screen.getByText('listpicker-close'))
|
||||
expect(fn('setListPickerMode')).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-MOB-COLSCR-022: the list picker duplicates in copy mode', () => {
|
||||
mocks.coll = makeHook({ listPickerMode: 'copy', selectedIds: [10], ownedLists: [list(1, 'Hamburg'), list(2, 'Kopenhagen')] })
|
||||
render(<MCollections />)
|
||||
|
||||
fireEvent.click(screen.getByText('listpicker-pick'))
|
||||
expect(fn('handleDuplicateToList')).toHaveBeenCalledWith(2)
|
||||
expect(fn('handleMoveToList')).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-COLSCR-023: the editor sheet reports a created list and a delete request', () => {
|
||||
mocks.coll = makeHook({ editorTarget: 'new' })
|
||||
render(<MCollections />)
|
||||
|
||||
expect(screen.getByText('editor:new')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('editor-created'))
|
||||
expect(fn('handleEditorCreated')).toHaveBeenCalledWith(77)
|
||||
fireEvent.click(screen.getByText('editor-delete'))
|
||||
expect(fn('setConfirmDeleteList')).toHaveBeenCalledWith(3)
|
||||
fireEvent.click(screen.getByText('editor-close'))
|
||||
expect(fn('setEditorTarget')).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-MOB-COLSCR-024: the delete-list confirm cancels and deletes', () => {
|
||||
mocks.coll = makeHook({ confirmDeleteList: 1 })
|
||||
render(<MCollections />)
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'collections.deleteList' })).toBeInTheDocument()
|
||||
expect(screen.getByText('collections.deleteListConfirm')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
|
||||
expect(fn('setConfirmDeleteList')).toHaveBeenCalledWith(null)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /common.delete/ }))
|
||||
expect(fn('handleDeleteList')).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Escape dismisses the sheet through the same handler.
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(fn('setConfirmDeleteList')).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('FE-MOB-COLSCR-025: the ?create= hand-off from the bottom nav opens the add sheet once', async () => {
|
||||
render(<MCollections />, { initialEntries: ['/collections?create=place'] })
|
||||
|
||||
await waitFor(() => expect(fn('setShowAddPlace')).toHaveBeenCalledWith(true))
|
||||
// The param is consumed so a re-render does not re-open the sheet.
|
||||
expect(fn('setShowAddPlace')).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-COLSCR-026: the union hand-off opens the sheet when at least one list can take the place', async () => {
|
||||
mocks.coll = makeHook({ isAllSaved: true, activeId: ALL_SAVED, activeCollection: null, canShare: false, canEdit: false })
|
||||
render(<MCollections />, { initialEntries: ['/collections?create=1'] })
|
||||
|
||||
await waitFor(() => expect(fn('setShowAddPlace')).toHaveBeenCalledWith(true))
|
||||
})
|
||||
|
||||
it('FE-MOB-COLSCR-027: without an editable target the hand-off is ignored', async () => {
|
||||
mocks.coll = makeHook({ activeId: null, activeCollection: null, canEdit: false, canShare: false, ownedLists: [] })
|
||||
render(<MCollections />, { initialEntries: ['/collections?create=place'] })
|
||||
|
||||
await waitFor(() => expect(screen.getByText('collections.title')).toBeInTheDocument())
|
||||
expect(fn('setShowAddPlace')).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-COLSCR-028: an unrelated query param leaves the sheet closed', () => {
|
||||
render(<MCollections />, { initialEntries: ['/collections?tab=map'] })
|
||||
expect(fn('setShowAddPlace')).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-COLSCR-029: desktop-only category and rating filters are cleared on phone (#1435)', async () => {
|
||||
mocks.coll = makeHook({ categoryFilter: 3, ratingFilter: 4 })
|
||||
render(<MCollections />)
|
||||
|
||||
await waitFor(() => expect(fn('setCategoryFilter')).toHaveBeenCalledWith('all'))
|
||||
expect(fn('setRatingFilter')).toHaveBeenCalledWith('all')
|
||||
})
|
||||
|
||||
it('FE-MOB-COLSCR-030: the add sheet has no fixed target while the union is active', () => {
|
||||
mocks.coll = makeHook({
|
||||
isAllSaved: true, activeId: ALL_SAVED, activeCollection: null, canShare: false, showAddPlace: true,
|
||||
})
|
||||
render(<MCollections />)
|
||||
|
||||
expect(screen.getByText('add-target:null::1')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,420 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { render, screen, fireEvent, waitFor } from '../../../helpers/render';
|
||||
import { server } from '../../../helpers/msw/server';
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store';
|
||||
import { buildSettings } from '../../../helpers/factories';
|
||||
import {
|
||||
resolveMobileDashOrder, useMobileDashOrder, useMobileDashVisibility, MobileDashWidget,
|
||||
} from '../../../../src/mobile/screens/dashboard/MDashWidgets';
|
||||
import { useSettingsStore } from '../../../../src/store/settingsStore';
|
||||
import { useAddonStore } from '../../../../src/store/addonStore';
|
||||
import type { UpcomingReservation } from '../../../../src/pages/dashboard/dashboardModel';
|
||||
|
||||
// FE-MOB-DWID-001 onwards
|
||||
|
||||
const RATES = [
|
||||
{ date: '2026-07-01', base: 'EUR', quote: 'USD', rate: 1.1 },
|
||||
{ date: '2026-07-01', base: 'EUR', quote: 'CHF', rate: 0.95 },
|
||||
];
|
||||
|
||||
function fxHandler(body: unknown = RATES) {
|
||||
server.use(http.get('https://api.frankfurter.dev/v2/rates', () => HttpResponse.json(body)));
|
||||
}
|
||||
|
||||
function collectionsHandler(collections: unknown[]) {
|
||||
server.use(http.get('/api/addons/collections', () => HttpResponse.json({ collections })));
|
||||
}
|
||||
|
||||
function LocationEcho() {
|
||||
const location = useLocation();
|
||||
return <span data-testid="loc">{location.pathname}</span>;
|
||||
}
|
||||
|
||||
function enableCollectionsAddon(enabled: boolean) {
|
||||
useAddonStore.setState({
|
||||
addons: [{ id: 'collections', name: 'Collections', type: 'global', icon: 'bookmark', enabled }],
|
||||
loaded: true,
|
||||
} as never);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
fxHandler();
|
||||
collectionsHandler([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('resolveMobileDashOrder', () => {
|
||||
it('FE-MOB-DWID-001: falls back to the built-in order without a stored one', () => {
|
||||
expect(resolveMobileDashOrder(undefined)).toEqual([
|
||||
'trips', 'currency', 'collections', 'timezones', 'upcomingReservations',
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-MOB-DWID-002: keeps the stored order and appends the missing blocks', () => {
|
||||
expect(resolveMobileDashOrder(['timezones', 'trips'])).toEqual([
|
||||
'timezones', 'trips', 'currency', 'collections', 'upcomingReservations',
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-MOB-DWID-003: drops unknown and duplicated tokens', () => {
|
||||
expect(resolveMobileDashOrder(['currency', 'nope', 'currency', 'trips'])).toEqual([
|
||||
'currency', 'trips', 'collections', 'timezones', 'upcomingReservations',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mobile dashboard order + visibility hooks', () => {
|
||||
it('FE-MOB-DWID-004: useMobileDashOrder reads the appearance blob', () => {
|
||||
seedStore(useSettingsStore, {
|
||||
settings: buildSettings({
|
||||
appearance: { dashboard: { mobileOrder: ['upcomingReservations', 'trips'] } },
|
||||
} as never),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMobileDashOrder());
|
||||
|
||||
expect(result.current[0]).toBe('upcomingReservations');
|
||||
expect(result.current[1]).toBe('trips');
|
||||
});
|
||||
|
||||
it('FE-MOB-DWID-005: trips is always visible, widgets follow their flags', () => {
|
||||
enableCollectionsAddon(true);
|
||||
seedStore(useSettingsStore, {
|
||||
settings: buildSettings({
|
||||
appearance: { dashboard: { mobile: { currency: false, collections: true, timezones: true, upcomingReservations: false } } },
|
||||
} as never),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMobileDashVisibility());
|
||||
|
||||
expect(result.current).toEqual({
|
||||
trips: true,
|
||||
currency: false,
|
||||
collections: true,
|
||||
timezones: true,
|
||||
upcomingReservations: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-MOB-DWID-006: collections stays hidden while the addon is off', () => {
|
||||
enableCollectionsAddon(false);
|
||||
|
||||
const { result } = renderHook(() => useMobileDashVisibility());
|
||||
|
||||
expect(result.current.collections).toBe(false);
|
||||
expect(result.current.currency).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MobileDashWidget', () => {
|
||||
it('FE-MOB-DWID-007: renders nothing for the trips block', () => {
|
||||
const { container } = render(<MobileDashWidget id="trips" upcoming={[]} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCurrencyWidget', () => {
|
||||
it('FE-MOB-DWID-008: converts the amount with the fetched rate', async () => {
|
||||
render(<MobileDashWidget id="currency" upcoming={[]} />);
|
||||
|
||||
expect(screen.getByText('Currency')).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByText('1 EUR = 1.1000 USD')).toBeInTheDocument());
|
||||
// 100 (default amount) * 1.1
|
||||
expect(screen.getByText('110.00')).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('From'), { target: { value: '250,5' } });
|
||||
expect(screen.getByText('275.55')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-DWID-009: a non-array response leaves the rate unavailable', async () => {
|
||||
fxHandler({ error: 'nope' });
|
||||
render(<MobileDashWidget id="currency" upcoming={[]} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Rate unavailable')).toBeInTheDocument());
|
||||
expect(screen.getByText('—')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-DWID-010: a failed request leaves the rate unavailable', async () => {
|
||||
server.use(http.get('https://api.frankfurter.dev/v2/rates', () => HttpResponse.error()));
|
||||
render(<MobileDashWidget id="currency" upcoming={[]} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Rate unavailable')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-MOB-DWID-011: swapping writes the reversed pair to the settings store', async () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ dashboard_fx_from: 'EUR', dashboard_fx_to: 'USD' }) });
|
||||
render(<MobileDashWidget id="currency" upcoming={[]} />);
|
||||
await waitFor(() => expect(screen.getByText('1 EUR = 1.1000 USD')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Swap currencies' }));
|
||||
|
||||
await waitFor(() => {
|
||||
const s = useSettingsStore.getState().settings;
|
||||
expect(s.dashboard_fx_from).toBe('USD');
|
||||
expect(s.dashboard_fx_to).toBe('EUR');
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-MOB-DWID-012: the native picker writes the chosen currency', async () => {
|
||||
render(<MobileDashWidget id="currency" upcoming={[]} />);
|
||||
await waitFor(() => expect(screen.getByText('1 EUR = 1.1000 USD')).toBeInTheDocument());
|
||||
|
||||
fireEvent.change(screen.getByLabelText('USD'), { target: { value: 'CHF' } });
|
||||
|
||||
await waitFor(() => expect(useSettingsStore.getState().settings.dashboard_fx_to).toBe('CHF'));
|
||||
});
|
||||
|
||||
it('FE-MOB-DWID-013: refresh re-requests the rates', async () => {
|
||||
let calls = 0;
|
||||
server.use(http.get('https://api.frankfurter.dev/v2/rates', () => { calls++; return HttpResponse.json(RATES); }));
|
||||
render(<MobileDashWidget id="currency" upcoming={[]} />);
|
||||
await waitFor(() => expect(calls).toBe(1));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Refresh rates' }));
|
||||
|
||||
await waitFor(() => expect(calls).toBe(2));
|
||||
});
|
||||
|
||||
it('FE-MOB-DWID-014: migrates the legacy fx keys into settings once', async () => {
|
||||
localStorage.setItem('trek_fx_from', 'CAD');
|
||||
localStorage.setItem('trek_fx_to', 'CHF');
|
||||
seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true });
|
||||
|
||||
render(<MobileDashWidget id="currency" upcoming={[]} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem('trek_fx_from')).toBeNull();
|
||||
expect(localStorage.getItem('trek_fx_to')).toBeNull();
|
||||
});
|
||||
expect(useSettingsStore.getState().settings.dashboard_fx_from).toBe('CAD');
|
||||
});
|
||||
|
||||
it('FE-MOB-DWID-015: keeps the legacy fx keys when the write fails', async () => {
|
||||
server.use(
|
||||
http.put('/api/settings', () => new HttpResponse(null, { status: 500 })),
|
||||
http.post('/api/settings/bulk', () => new HttpResponse(null, { status: 500 })),
|
||||
);
|
||||
localStorage.setItem('trek_fx_from', 'CAD');
|
||||
seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true });
|
||||
|
||||
render(<MobileDashWidget id="currency" upcoming={[]} />);
|
||||
|
||||
await waitFor(() => expect(useSettingsStore.getState().settings.dashboard_fx_from).toBe('CAD'));
|
||||
expect(localStorage.getItem('trek_fx_from')).toBe('CAD');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCollectionsWidget', () => {
|
||||
it('FE-MOB-DWID-016: shows the empty hint when there is no list', async () => {
|
||||
render(<MobileDashWidget id="collections" upcoming={[]} />);
|
||||
|
||||
expect(await screen.findByText('No saved places yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-DWID-017: renders at most four lists with their place counts', async () => {
|
||||
collectionsHandler([
|
||||
{ id: 1, name: 'Tokyo eats', color: '#ff0000', place_count: 12 },
|
||||
{ id: 2, name: 'Museums', color: null, place_count: 3 },
|
||||
{ id: 3, name: 'Bars', color: null, place_count: null },
|
||||
{ id: 4, name: 'Parks', color: null, place_count: 1 },
|
||||
{ id: 5, name: 'Hidden', color: null, place_count: 9 },
|
||||
]);
|
||||
render(<MobileDashWidget id="collections" upcoming={[]} />);
|
||||
|
||||
expect(await screen.findByText('Tokyo eats')).toBeInTheDocument();
|
||||
expect(screen.getByText('Parks')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Hidden')).not.toBeInTheDocument();
|
||||
// A list without a stored count renders a zero rather than blank.
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-DWID-032: the header arrow and a badge open the collections pages', async () => {
|
||||
collectionsHandler([{ id: 7, name: 'Tokyo eats', color: '#f00', place_count: 2 }]);
|
||||
render(<><MobileDashWidget id="collections" upcoming={[]} /><LocationEcho /></>);
|
||||
|
||||
fireEvent.click(await screen.findByText('Tokyo eats'));
|
||||
expect(screen.getByTestId('loc')).toHaveTextContent('/collections/7');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Collections' }));
|
||||
expect(screen.getByTestId('loc')).toHaveTextContent('/collections');
|
||||
});
|
||||
|
||||
it('FE-MOB-DWID-018: a failed fetch falls back to the empty hint', async () => {
|
||||
server.use(http.get('/api/addons/collections', () => new HttpResponse(null, { status: 500 })));
|
||||
render(<MobileDashWidget id="collections" upcoming={[]} />);
|
||||
|
||||
expect(await screen.findByText('No saved places yet')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MTimezonesWidget', () => {
|
||||
it('FE-MOB-DWID-019: renders the stored zones with their local time', () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ dashboard_timezones: ['Asia/Tokyo'] }) });
|
||||
render(<MobileDashWidget id="timezones" upcoming={[]} />);
|
||||
|
||||
expect(screen.getByText('Timezones')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tokyo')).toBeInTheDocument();
|
||||
expect(screen.getByText(/^\d{2}:\d{2}$/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-DWID-020: an explicitly empty list shows the empty hint', () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ dashboard_timezones: [] }) });
|
||||
render(<MobileDashWidget id="timezones" upcoming={[]} />);
|
||||
|
||||
expect(screen.getByText('No other timezones yet — add one with +')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-DWID-021: the edit mode adds and removes a zone', async () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ dashboard_timezones: ['Asia/Tokyo'] }) });
|
||||
render(<MobileDashWidget id="timezones" upcoming={[]} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add timezone' }));
|
||||
fireEvent.change(screen.getByRole('combobox', { name: 'Add timezone' }), { target: { value: 'Europe/Berlin' } });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(useSettingsStore.getState().settings.dashboard_timezones).toEqual(['Asia/Tokyo', 'Europe/Berlin']));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add timezone' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove Tokyo' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(useSettingsStore.getState().settings.dashboard_timezones).toEqual(['Europe/Berlin']));
|
||||
});
|
||||
|
||||
it('FE-MOB-DWID-022: picking an already-listed zone changes nothing', async () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ dashboard_timezones: ['Asia/Tokyo'] }) });
|
||||
render(<MobileDashWidget id="timezones" upcoming={[]} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add timezone' }));
|
||||
fireEvent.change(screen.getByRole('combobox', { name: 'Add timezone' }), { target: { value: '' } });
|
||||
|
||||
// The picker closes again and the stored list is untouched.
|
||||
await waitFor(() => expect(screen.queryByRole('combobox', { name: 'Add timezone' })).not.toBeInTheDocument());
|
||||
expect(useSettingsStore.getState().settings.dashboard_timezones).toEqual(['Asia/Tokyo']);
|
||||
});
|
||||
|
||||
it('FE-MOB-DWID-023: the clock ticks on its interval', () => {
|
||||
vi.useFakeTimers();
|
||||
const nowSpy = vi.spyOn(Date.prototype, 'toLocaleTimeString');
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ dashboard_timezones: ['Asia/Tokyo'] }) });
|
||||
render(<MobileDashWidget id="timezones" upcoming={[]} />);
|
||||
const before = nowSpy.mock.calls.length;
|
||||
|
||||
act(() => { vi.advanceTimersByTime(30000); });
|
||||
|
||||
expect(nowSpy.mock.calls.length).toBeGreaterThan(before);
|
||||
nowSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('FE-MOB-DWID-024: migrates the legacy timezone list into settings', async () => {
|
||||
localStorage.setItem('trek_dashboard_tz', JSON.stringify(['America/New_York']));
|
||||
seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true });
|
||||
|
||||
render(<MobileDashWidget id="timezones" upcoming={[]} />);
|
||||
|
||||
await waitFor(() => expect(localStorage.getItem('trek_dashboard_tz')).toBeNull());
|
||||
expect(useSettingsStore.getState().settings.dashboard_timezones).toEqual(['America/New_York']);
|
||||
});
|
||||
|
||||
it('FE-MOB-DWID-025: drops a malformed legacy timezone value', async () => {
|
||||
localStorage.setItem('trek_dashboard_tz', 'not-json');
|
||||
seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true });
|
||||
|
||||
render(<MobileDashWidget id="timezones" upcoming={[]} />);
|
||||
|
||||
await waitFor(() => expect(localStorage.getItem('trek_dashboard_tz')).toBeNull());
|
||||
expect(useSettingsStore.getState().settings.dashboard_timezones).toBeUndefined();
|
||||
});
|
||||
|
||||
it('FE-MOB-DWID-026: drops a legacy value that is not a list', async () => {
|
||||
localStorage.setItem('trek_dashboard_tz', JSON.stringify({ tz: 'Asia/Tokyo' }));
|
||||
seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true });
|
||||
|
||||
render(<MobileDashWidget id="timezones" upcoming={[]} />);
|
||||
|
||||
await waitFor(() => expect(localStorage.getItem('trek_dashboard_tz')).toBeNull());
|
||||
expect(useSettingsStore.getState().settings.dashboard_timezones).toBeUndefined();
|
||||
});
|
||||
|
||||
it('FE-MOB-DWID-027: keeps the legacy value when the write fails', async () => {
|
||||
server.use(
|
||||
http.put('/api/settings', () => new HttpResponse(null, { status: 500 })),
|
||||
http.post('/api/settings/bulk', () => new HttpResponse(null, { status: 500 })),
|
||||
);
|
||||
localStorage.setItem('trek_dashboard_tz', JSON.stringify(['America/New_York']));
|
||||
seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true });
|
||||
|
||||
render(<MobileDashWidget id="timezones" upcoming={[]} />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(useSettingsStore.getState().settings.dashboard_timezones).toEqual(['America/New_York']));
|
||||
expect(localStorage.getItem('trek_dashboard_tz')).toBe(JSON.stringify(['America/New_York']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('legacy settings migration', () => {
|
||||
it('FE-MOB-DWID-033: loaded settings without legacy keys write nothing', async () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true });
|
||||
|
||||
render(<MobileDashWidget id="currency" upcoming={[]} />);
|
||||
render(<MobileDashWidget id="timezones" upcoming={[]} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('1 EUR = 1.1000 USD')).toBeInTheDocument());
|
||||
expect(useSettingsStore.getState().settings.dashboard_fx_from).toBeUndefined();
|
||||
expect(useSettingsStore.getState().settings.dashboard_timezones).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MUpcomingWidget', () => {
|
||||
const res = (over: Partial<UpcomingReservation> = {}): UpcomingReservation => ({
|
||||
id: 1, trip_id: 7, title: 'teamLab Planets', type: 'ticket',
|
||||
reservation_time: null, day_date: '2026-09-02', location: 'Toyosu',
|
||||
...over,
|
||||
});
|
||||
|
||||
it('FE-MOB-DWID-028: shows the empty hint without reservations', () => {
|
||||
render(<MobileDashWidget id="upcomingReservations" upcoming={[]} />);
|
||||
|
||||
expect(screen.getByText('Upcoming reservations')).toBeInTheDocument();
|
||||
expect(screen.getByText('Nothing booked yet.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-DWID-029: builds the subtitle from date, time and place', () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ time_format: '24h' }) });
|
||||
render(
|
||||
<MobileDashWidget
|
||||
id="upcomingReservations"
|
||||
upcoming={[res({ reservation_time: '2026-09-03T19:30:00', location: null, place_name: 'Narita' })]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Sep 3 · 19:30 · Narita')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-DWID-030: an unparseable date degrades to the remaining parts', () => {
|
||||
render(
|
||||
<MobileDashWidget
|
||||
id="upcomingReservations"
|
||||
upcoming={[res({ day_date: 'not-a-date', location: null, place_name: null, trip_title: 'Japan' })]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Japan')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-DWID-031: tapping a reservation opens its trip on the bookings tab', () => {
|
||||
render(<MobileDashWidget id="upcomingReservations" upcoming={[res({ type: 'flight' })]} />);
|
||||
|
||||
fireEvent.click(screen.getByText('teamLab Planets'));
|
||||
|
||||
expect(sessionStorage.getItem('trip-tab-7')).toBe('buchungen');
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,30 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '../../../helpers/render';
|
||||
import { render, screen, fireEvent, waitFor, within } from '../../../helpers/render';
|
||||
import MDashboard from '../../../../src/mobile/screens/dashboard/MDashboard';
|
||||
import { tripsApi } from '../../../../src/api/client';
|
||||
import { useAuthStore } from '../../../../src/store/authStore';
|
||||
import { useInAppNotificationStore } from '../../../../src/store/inAppNotificationStore';
|
||||
import { usePluginStore } from '../../../../src/store/pluginStore';
|
||||
import type { TripCardBadge } from '../../../../src/api/client';
|
||||
import type { DashboardTrip } from '../../../../src/pages/dashboard/dashboardModel';
|
||||
|
||||
// FE-MOB-DASH-001 onwards
|
||||
|
||||
const mocks = vi.hoisted(() => ({ dash: {} as Record<string, unknown> }));
|
||||
const mocks = vi.hoisted(() => ({
|
||||
dash: {} as Record<string, unknown>,
|
||||
badges: {} as Record<number, unknown[]>,
|
||||
}));
|
||||
|
||||
vi.mock('../../../../src/pages/dashboard/useDashboard', () => ({
|
||||
useDashboard: () => mocks.dash,
|
||||
}));
|
||||
|
||||
// The badge lookup is a network hook of its own; the screen only cares what it returns.
|
||||
vi.mock('../../../../src/components/Plugins/TripCardBadges', () => ({
|
||||
useTripCardBadges: () => (tripId: number) => mocks.badges[tripId] ?? [],
|
||||
}));
|
||||
|
||||
const iso = (offsetDays: number): string => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + offsetDays);
|
||||
@@ -66,6 +77,8 @@ function buildDash(over: Record<string, unknown> = {}): Record<string, unknown>
|
||||
describe('MDashboard', () => {
|
||||
beforeEach(() => {
|
||||
mocks.dash = buildDash();
|
||||
mocks.badges = {};
|
||||
usePluginStore.setState({ plugins: [], loaded: true });
|
||||
useAuthStore.setState({
|
||||
isAuthenticated: true,
|
||||
user: { id: 1, username: 'Maurice', email: 'maurice@trek.app', role: 'user', avatar_url: '' } as never,
|
||||
@@ -122,4 +135,408 @@ describe('MDashboard', () => {
|
||||
fireEvent.click(screen.getByText('teamLab Planets'));
|
||||
expect(sessionStorage.getItem('trip-tab-7')).toBe('buchungen');
|
||||
});
|
||||
|
||||
it('FE-MOB-DASH-005: a failed load shows the retry banner', () => {
|
||||
const retryLoad = vi.fn();
|
||||
mocks.dash = buildDash({ loadError: true, retryLoad });
|
||||
render(<MDashboard />);
|
||||
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('dashboard.loadErrorBanner');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'dashboard.retry' }));
|
||||
expect(retryLoad).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-DASH-006: the empty state opens a blank create sheet', () => {
|
||||
const setShowForm = vi.fn();
|
||||
const setEditingTrip = vi.fn();
|
||||
mocks.dash = buildDash({ setShowForm, setEditingTrip });
|
||||
render(<MDashboard />);
|
||||
|
||||
expect(screen.getByText('dashboard.emptyTitle')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'dashboard.emptyButton' }));
|
||||
|
||||
expect(setEditingTrip).toHaveBeenCalledWith(null);
|
||||
expect(setShowForm).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('FE-MOB-DASH-007: a still-loading dashboard shows no empty state', () => {
|
||||
mocks.dash = buildDash({ isLoading: true });
|
||||
render(<MDashboard />);
|
||||
|
||||
expect(screen.queryByText('dashboard.emptyTitle')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-DASH-008: demo mode adds the banner', () => {
|
||||
mocks.dash = buildDash({ demoMode: true });
|
||||
render(<MDashboard />);
|
||||
|
||||
expect(screen.getByText(/demo/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-DASH-009: unread notifications mark the bell and it opens the list', () => {
|
||||
const navigate = vi.fn();
|
||||
mocks.dash = buildDash({ navigate });
|
||||
useInAppNotificationStore.setState({ unreadCount: 3, fetchUnreadCount: async () => {} });
|
||||
render(<MDashboard />);
|
||||
|
||||
const bell = screen.getByRole('button', { name: 'notifications.title' });
|
||||
expect(bell.querySelector('span[aria-hidden]')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(bell);
|
||||
expect(navigate).toHaveBeenCalledWith('/notifications');
|
||||
});
|
||||
|
||||
it('FE-MOB-DASH-010: the avatar button toggles the user menu', () => {
|
||||
render(<MDashboard />);
|
||||
const avatar = screen.getByRole('button', { name: 'nav.profile' });
|
||||
|
||||
expect(avatar).toHaveTextContent('M');
|
||||
expect(avatar).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
fireEvent.click(avatar);
|
||||
expect(avatar).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
it('FE-MOB-DASH-011: a stored avatar replaces the initial', () => {
|
||||
useAuthStore.setState({
|
||||
isAuthenticated: true,
|
||||
user: { id: 1, username: 'Maurice', email: 'm@trek.app', role: 'user', avatar_url: '/uploads/avatars/m.jpg' } as never,
|
||||
});
|
||||
render(<MDashboard />);
|
||||
|
||||
const avatar = screen.getByRole('button', { name: 'nav.profile' });
|
||||
expect(avatar.querySelector('img')).toHaveAttribute('src', '/uploads/avatars/m.jpg');
|
||||
});
|
||||
|
||||
it('FE-MOB-DASH-012: the calendar action opens the all-trips subscribe dialog', () => {
|
||||
render(<MDashboard />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'dashboard.subscribeAllTrips' }));
|
||||
|
||||
expect(screen.getByText('dashboard.subscribeAllTripsDesc')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-DASH-013: the delete sheet names the trip and runs the confirm action', () => {
|
||||
const confirmDelete = vi.fn();
|
||||
const setDeleteTrip = vi.fn();
|
||||
mocks.dash = buildDash({ deleteTrip: buildTrip({ title: 'Iceland' }), confirmDelete, setDeleteTrip });
|
||||
render(<MDashboard />);
|
||||
|
||||
const sheet = screen.getByRole('dialog', { name: 'common.delete' });
|
||||
expect(within(sheet).getByText('dashboard.confirm.delete')).toBeInTheDocument();
|
||||
|
||||
// The sheet's own chrome uses the real translations, the props the mocked t.
|
||||
fireEvent.click(within(sheet).getByText('Cancel'));
|
||||
expect(setDeleteTrip).toHaveBeenCalledWith(null);
|
||||
|
||||
fireEvent.click(within(sheet).getAllByText('common.delete')[1]);
|
||||
expect(confirmDelete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-DASH-014: the copy sheet confirms the duplicate', () => {
|
||||
const confirmCopy = vi.fn();
|
||||
mocks.dash = buildDash({ copyTrip: buildTrip({ title: 'Iceland' }), confirmCopy });
|
||||
render(<MDashboard />);
|
||||
|
||||
const sheet = screen.getByRole('dialog', { name: 'dashboard.confirm.copy.title' });
|
||||
expect(within(sheet).getByText('Iceland')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(sheet).getByText('dashboard.confirm.copy.confirm'));
|
||||
expect(confirmCopy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-DASH-015: the list layout adds an archive action and the day/place stats', () => {
|
||||
const handleArchive = vi.fn();
|
||||
mocks.dash = buildDash({
|
||||
viewMode: 'list',
|
||||
handleArchive,
|
||||
gridTrips: [buildTrip({ id: 4, title: 'Lisbon', day_count: 12, place_count: 30, shared_count: 1 })],
|
||||
});
|
||||
render(<MDashboard />);
|
||||
|
||||
expect(screen.getByText('12')).toBeInTheDocument();
|
||||
expect(screen.getByText('30')).toBeInTheDocument();
|
||||
expect(screen.getByText('dashboard.card.buddyOne')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'dashboard.archive' }));
|
||||
expect(handleArchive).toHaveBeenCalledWith(4);
|
||||
});
|
||||
|
||||
it('FE-MOB-DASH-016: an undated trip renders dashes instead of a range', () => {
|
||||
mocks.dash = buildDash({ gridTrips: [buildTrip({ id: 5, start_date: null, end_date: null })] });
|
||||
render(<MDashboard />);
|
||||
|
||||
const card = screen.getByText('Japan 2026').closest('[role="button"]') as HTMLElement;
|
||||
expect(within(card).getAllByText('—')).toHaveLength(2);
|
||||
expect(within(card).getByText('dashboard.card.idea')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ is_archived: 1 }, 'dashboard.archived'],
|
||||
[{ start_date: iso(0), end_date: null }, 'dashboard.status.today'],
|
||||
[{ start_date: iso(1), end_date: null }, 'dashboard.status.tomorrow'],
|
||||
[{ start_date: iso(10), end_date: null }, 'dashboard.mobile.inDays'],
|
||||
[{ start_date: iso(200), end_date: null }, 'dashboard.mobile.inMonths'],
|
||||
[{ start_date: iso(-20), end_date: iso(-10) }, 'dashboard.mobile.completed'],
|
||||
] as Array<[Partial<DashboardTrip>, string]>)(
|
||||
'FE-MOB-DASH-017: the card badge follows the trip state (%#)',
|
||||
(over, label) => {
|
||||
mocks.dash = buildDash({ gridTrips: [buildTrip({ id: 6, ...over })] });
|
||||
render(<MDashboard />);
|
||||
|
||||
// The filter segments reuse some of these labels, so assert on the card itself.
|
||||
const card = screen.getByText('Japan 2026').closest('[role="button"]') as HTMLElement;
|
||||
expect(within(card).getByText(label)).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it('FE-MOB-DASH-018: plugin badges render as chips, linked ones as anchors', () => {
|
||||
mocks.badges = {
|
||||
8: [
|
||||
{ pluginId: 'p', id: 'a', label: 'Weather', value: '21°', tone: 'success' },
|
||||
{ pluginId: 'p', id: 'b', label: 'Docs', value: '', tone: 'warn', url: 'https://example.com' },
|
||||
] as TripCardBadge[],
|
||||
};
|
||||
mocks.dash = buildDash({ gridTrips: [buildTrip({ id: 8, title: 'Oslo' })] });
|
||||
render(<MDashboard />);
|
||||
|
||||
expect(screen.getByText('21°')).toBeInTheDocument();
|
||||
const link = screen.getByText('Docs').closest('a') as HTMLAnchorElement;
|
||||
expect(link).toHaveAttribute('href', 'https://example.com');
|
||||
// The chip must not open the card underneath it.
|
||||
fireEvent.click(link);
|
||||
expect((mocks.dash.navigate as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-DASH-019: dashboard widget plugins render below the blocks', () => {
|
||||
usePluginStore.setState({
|
||||
plugins: [
|
||||
{ id: 'w1', name: 'Packing helper', type: 'widget', icon: null },
|
||||
{ id: 'h1', name: 'Hero widget', type: 'widget', icon: null, slot: 'hero' },
|
||||
] as never,
|
||||
loaded: true,
|
||||
});
|
||||
mocks.dash = buildDash({ spotlight: buildTrip() });
|
||||
render(<MDashboard />);
|
||||
|
||||
expect(screen.getByText('Packing helper')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Hero widget')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-DASH-020: a card action does not open the trip underneath it', () => {
|
||||
const navigate = vi.fn();
|
||||
const setCopyTrip = vi.fn();
|
||||
mocks.dash = buildDash({ navigate, setCopyTrip, gridTrips: [buildTrip({ id: 9, title: 'Oslo' })] });
|
||||
render(<MDashboard />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'dashboard.aria.duplicate' }));
|
||||
|
||||
expect(setCopyTrip).toHaveBeenCalled();
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-DASH-021: pressing Enter on a card opens the trip', () => {
|
||||
const navigate = vi.fn();
|
||||
mocks.dash = buildDash({ navigate, gridTrips: [buildTrip({ id: 9, title: 'Oslo' })] });
|
||||
render(<MDashboard />);
|
||||
|
||||
fireEvent.keyDown(screen.getByText('Oslo').closest('[role="button"]') as HTMLElement, { key: 'Enter' });
|
||||
|
||||
expect(navigate).toHaveBeenCalledWith('/trips/9');
|
||||
});
|
||||
|
||||
it('FE-MOB-DASH-022: an upcoming spotlight counts down instead of showing progress', () => {
|
||||
mocks.dash = buildDash({
|
||||
spotlight: buildTrip({ start_date: iso(4), end_date: iso(9), cover_image: '/uploads/covers/x.jpg' }),
|
||||
});
|
||||
const { container } = render(<MDashboard />);
|
||||
|
||||
expect(screen.getByText('dashboard.hero.badgeNext')).toBeInTheDocument();
|
||||
expect(screen.getByText('dashboard.mobile.inDays')).toBeInTheDocument();
|
||||
expect(container.querySelector('img[src="/uploads/covers/x.jpg"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-DASH-023: a finished spotlight is labelled as recent', () => {
|
||||
mocks.dash = buildDash({ spotlight: buildTrip({ start_date: iso(-20), end_date: iso(-10), day_count: 1 }) });
|
||||
render(<MDashboard />);
|
||||
|
||||
expect(screen.getByText('dashboard.hero.badgeRecent')).toBeInTheDocument();
|
||||
expect(screen.getByText('dashboard.mobile.spotlightDayOne')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-DASH-024: the spotlight opens on Enter as well as on click', () => {
|
||||
const navigate = vi.fn();
|
||||
mocks.dash = buildDash({ navigate, spotlight: buildTrip({ id: 12 }) });
|
||||
render(<MDashboard />);
|
||||
|
||||
fireEvent.keyDown(screen.getByText('Japan 2026').closest('[role="button"]') as HTMLElement, { key: 'Enter' });
|
||||
|
||||
expect(navigate).toHaveBeenCalledWith('/trips/12');
|
||||
});
|
||||
|
||||
it('FE-MOB-DASH-025: editing a card hands the trip to the create/edit sheet', () => {
|
||||
const setEditingTrip = vi.fn();
|
||||
const setShowForm = vi.fn();
|
||||
const trip = buildTrip({ id: 13, title: 'Oslo' });
|
||||
mocks.dash = buildDash({ setEditingTrip, setShowForm, gridTrips: [trip] });
|
||||
render(<MDashboard />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.edit' }));
|
||||
|
||||
expect(setEditingTrip).toHaveBeenCalledWith(trip);
|
||||
expect(setShowForm).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('FE-MOB-DASH-026: the mobile widgets render in the configured block order', () => {
|
||||
mocks.dash = buildDash();
|
||||
render(<MDashboard />);
|
||||
|
||||
// The widget panels bring their own translations rather than the mocked hook's t.
|
||||
expect(screen.getByText('Currency')).toBeInTheDocument();
|
||||
expect(screen.getByText('Timezones')).toBeInTheDocument();
|
||||
expect(screen.getByText('Upcoming reservations')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-DASH-027: a malformed date renders a dash instead of crashing', () => {
|
||||
mocks.dash = buildDash({ gridTrips: [buildTrip({ id: 14, start_date: 'not-a-date', end_date: iso(3) })] });
|
||||
render(<MDashboard />);
|
||||
|
||||
const card = screen.getByText('Japan 2026').closest('[role="button"]') as HTMLElement;
|
||||
expect(within(card).getAllByText('—')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-DASH-028: the archived card duplicates and deletes through the hook', () => {
|
||||
const setCopyTrip = vi.fn();
|
||||
const setDeleteTrip = vi.fn();
|
||||
const trip = buildTrip({ id: 15, title: 'Iceland', is_archived: 1 });
|
||||
mocks.dash = buildDash({ tripFilter: 'archive', setCopyTrip, setDeleteTrip, gridTrips: [trip] });
|
||||
render(<MDashboard />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'dashboard.aria.duplicate' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }));
|
||||
|
||||
expect(setCopyTrip).toHaveBeenCalledWith(trip);
|
||||
expect(setDeleteTrip).toHaveBeenCalledWith(trip);
|
||||
});
|
||||
|
||||
it('FE-MOB-DASH-029: a grid card deletes through the hook', () => {
|
||||
const setDeleteTrip = vi.fn();
|
||||
const trip = buildTrip({ id: 16, title: 'Oslo' });
|
||||
mocks.dash = buildDash({ setDeleteTrip, gridTrips: [trip] });
|
||||
render(<MDashboard />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }));
|
||||
|
||||
expect(setDeleteTrip).toHaveBeenCalledWith(trip);
|
||||
});
|
||||
|
||||
it('FE-MOB-DASH-030: a list card opens its trip by click and by Enter', () => {
|
||||
const navigate = vi.fn();
|
||||
mocks.dash = buildDash({ viewMode: 'list', navigate, gridTrips: [buildTrip({ id: 17, title: 'Oslo' })] });
|
||||
render(<MDashboard />);
|
||||
const card = screen.getByText('Oslo').closest('[role="button"]') as HTMLElement;
|
||||
|
||||
fireEvent.click(card);
|
||||
fireEvent.keyDown(card, { key: 'Enter' });
|
||||
|
||||
expect(navigate).toHaveBeenNthCalledWith(1, '/trips/17');
|
||||
expect(navigate).toHaveBeenNthCalledWith(2, '/trips/17');
|
||||
});
|
||||
|
||||
it('FE-MOB-DASH-031: the brand tile scrolls the shell content back to the top', () => {
|
||||
// The screen scrolls inside the shell's container, not the window.
|
||||
const scroller = document.createElement('div');
|
||||
scroller.className = 'overflow-y-auto';
|
||||
const scrollTo = vi.fn();
|
||||
scroller.scrollTo = scrollTo;
|
||||
document.body.appendChild(scroller);
|
||||
render(<MDashboard />, { container: scroller });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'TREK' }));
|
||||
|
||||
expect(scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' });
|
||||
scroller.remove();
|
||||
});
|
||||
|
||||
it('FE-MOB-DASH-032: the user menu backdrop closes it again', () => {
|
||||
const { container } = render(<MDashboard />);
|
||||
const avatar = screen.getByRole('button', { name: 'nav.profile' });
|
||||
|
||||
fireEvent.click(avatar);
|
||||
fireEvent.click(container.querySelector('.fixed.inset-0') as HTMLElement);
|
||||
|
||||
expect(avatar).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
it('FE-MOB-DASH-033: closing the trip sheet resets the form state', () => {
|
||||
const setShowForm = vi.fn();
|
||||
const setEditingTrip = vi.fn();
|
||||
mocks.dash = buildDash({ showForm: true, setShowForm, setEditingTrip });
|
||||
render(<MDashboard />);
|
||||
|
||||
fireEvent.click(within(screen.getByRole('dialog')).getByText('Cancel'));
|
||||
|
||||
expect(setShowForm).toHaveBeenCalledWith(false);
|
||||
expect(setEditingTrip).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('FE-MOB-DASH-034: archiving from the edit sheet runs the matching hook action', () => {
|
||||
const handleArchive = vi.fn();
|
||||
const handleUnarchive = vi.fn();
|
||||
mocks.dash = buildDash({
|
||||
showForm: true, editingTrip: buildTrip({ id: 18 }), handleArchive, handleUnarchive,
|
||||
});
|
||||
render(<MDashboard />);
|
||||
|
||||
fireEvent.click(screen.getByText('Archive'));
|
||||
expect(handleArchive).toHaveBeenCalledWith(18);
|
||||
|
||||
mocks.dash = buildDash({
|
||||
showForm: true, editingTrip: buildTrip({ id: 18, is_archived: 1 }), handleArchive, handleUnarchive,
|
||||
});
|
||||
render(<MDashboard />);
|
||||
|
||||
fireEvent.click(screen.getByText('Restore'));
|
||||
expect(handleUnarchive).toHaveBeenCalledWith(18);
|
||||
});
|
||||
|
||||
it('FE-MOB-DASH-035: a cover uploaded in the sheet is patched into the trip list', async () => {
|
||||
const setTrips = vi.fn((updater: (prev: DashboardTrip[]) => DashboardTrip[]) => {
|
||||
expect(updater([buildTrip({ id: 19, cover_image: null })])[0].cover_image).toBe('/uploads/covers/n.jpg');
|
||||
});
|
||||
vi.spyOn(tripsApi, 'uploadCover').mockResolvedValue({ cover_image: '/uploads/covers/n.jpg' });
|
||||
mocks.dash = buildDash({ showForm: true, editingTrip: buildTrip({ id: 19 }), setTrips });
|
||||
render(<MDashboard />);
|
||||
|
||||
fireEvent.change(document.querySelector('input[type="file"]') as HTMLInputElement, {
|
||||
target: { files: [new File(['x'], 'c.png', { type: 'image/png' })] },
|
||||
});
|
||||
|
||||
await waitFor(() => expect(setTrips).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('FE-MOB-DASH-036: dismissing the copy sheet clears the pending trip', () => {
|
||||
const setCopyTrip = vi.fn();
|
||||
mocks.dash = buildDash({ copyTrip: buildTrip({ title: 'Iceland' }), setCopyTrip });
|
||||
render(<MDashboard />);
|
||||
|
||||
const sheet = screen.getByRole('dialog', { name: 'dashboard.confirm.copy.title' });
|
||||
fireEvent.click(within(sheet).getByText('Cancel'));
|
||||
|
||||
expect(setCopyTrip).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('FE-MOB-DASH-037: the subscribe dialog can be closed again', async () => {
|
||||
render(<MDashboard />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'dashboard.subscribeAllTrips' }));
|
||||
expect(screen.getByText('dashboard.subscribeAllTripsDesc')).toBeInTheDocument();
|
||||
|
||||
// The dialog's close control is icon-only; it is the first button of the card.
|
||||
const card = screen.getByText('dashboard.subscribeAllTripsDesc').parentElement as HTMLElement;
|
||||
fireEvent.click(card.querySelector('button') as HTMLElement);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('dashboard.subscribeAllTripsDesc')).not.toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor, act } from '../../../helpers/render';
|
||||
import MNewTripSheet from '../../../../src/mobile/screens/dashboard/MNewTripSheet';
|
||||
import { tripsApi } from '../../../../src/api/client';
|
||||
import { useAuthStore } from '../../../../src/store/authStore';
|
||||
import { usePermissionsStore } from '../../../../src/store/permissionsStore';
|
||||
import { buildUser } from '../../../helpers/factories';
|
||||
import type { DashboardTrip } from '../../../../src/pages/dashboard/dashboardModel';
|
||||
import type { Trip, TripCreateRequest } from '@trek/shared';
|
||||
|
||||
interface CoverSearchPhoto {
|
||||
id: string
|
||||
url: string
|
||||
thumb: string
|
||||
description?: string | null
|
||||
photographer?: string | null
|
||||
}
|
||||
|
||||
// FE-MOB-NTSH-001 onwards
|
||||
|
||||
vi.mock('../../../../src/utils/convertHeic', () => ({
|
||||
normalizeImageFile: (file: File) => Promise.resolve(file),
|
||||
}));
|
||||
|
||||
// The real pickers own a lot of unrelated calendar/portal behaviour; plain
|
||||
// controls keep this suite on the sheet's own logic.
|
||||
vi.mock('../../../../src/components/shared/CustomDateTimePicker', () => ({
|
||||
CustomDatePicker: ({ value, onChange, placeholder }: {
|
||||
value: string; onChange: (v: string) => void; placeholder: string
|
||||
}) => <input aria-label={placeholder} value={value} onChange={e => onChange(e.target.value)} />,
|
||||
}));
|
||||
|
||||
vi.mock('../../../../src/components/shared/CustomSelect', () => ({
|
||||
default: ({ value, onChange, disabled }: {
|
||||
value: string; onChange: (v: string) => void; disabled?: boolean
|
||||
}) => (
|
||||
<select aria-label="currency" value={value} disabled={disabled} onChange={e => onChange(e.target.value)}>
|
||||
<option value="EUR">EUR</option>
|
||||
<option value="USD">USD</option>
|
||||
</select>
|
||||
),
|
||||
}));
|
||||
|
||||
const origCreateObjectURL = URL.createObjectURL;
|
||||
|
||||
function buildDashTrip(over: Partial<DashboardTrip> = {}): DashboardTrip {
|
||||
return {
|
||||
id: 42, user_id: 1, title: 'Japan 2026', currency: 'EUR', is_archived: 0,
|
||||
start_date: '2026-05-01', end_date: '2026-05-08',
|
||||
description: 'cherry blossoms', cover_image: '/uploads/covers/a.jpg',
|
||||
...over,
|
||||
} as unknown as DashboardTrip;
|
||||
}
|
||||
|
||||
function fileInput(): HTMLInputElement {
|
||||
return document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
}
|
||||
|
||||
const png = () => new File(['x'], 'cover.png', { type: 'image/png' });
|
||||
|
||||
let toastCalls: Array<[string, string | undefined]>;
|
||||
|
||||
beforeEach(() => {
|
||||
toastCalls = [];
|
||||
window.__addToast = ((message: string, type?: string) => {
|
||||
toastCalls.push([message, type]);
|
||||
return 1;
|
||||
}) as unknown as typeof window.__addToast;
|
||||
URL.createObjectURL = (() => 'blob:preview') as unknown as typeof URL.createObjectURL;
|
||||
useAuthStore.setState({ isAuthenticated: true, user: buildUser({ id: 1, role: 'user' }) });
|
||||
usePermissionsStore.setState({ permissions: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
URL.createObjectURL = origCreateObjectURL;
|
||||
delete window.__addToast;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('MNewTripSheet', () => {
|
||||
it('FE-MOB-NTSH-001: create mode shows the empty form plus the no-date hint', () => {
|
||||
render(<MNewTripSheet open trip={null} onClose={() => {}} onSave={() => {}} />);
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'Create New Trip' })).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('e.g. Summer in Japan')).toHaveValue('');
|
||||
expect(screen.getByText(/7 default days will be created/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Add cover image')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-002: edit mode preloads the trip and hides the no-date hint', () => {
|
||||
render(<MNewTripSheet open trip={buildDashTrip()} onClose={() => {}} onSave={() => {}} />);
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'Edit Trip' })).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('e.g. Summer in Japan')).toHaveValue('Japan 2026');
|
||||
expect(screen.getByPlaceholderText('What is this trip about?')).toHaveValue('cherry blossoms');
|
||||
expect(screen.getByLabelText('Start Date')).toHaveValue('2026-05-01');
|
||||
expect(screen.queryByText(/7 default days will be created/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-003: an empty title blocks the save', () => {
|
||||
const onSave = vi.fn(async () => undefined);
|
||||
render(<MNewTripSheet open trip={null} onClose={() => {}} onSave={onSave} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create New Trip' }));
|
||||
|
||||
expect(screen.getByText('Title is required')).toBeInTheDocument();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-004: an end date before the start blocks the save', () => {
|
||||
const onSave = vi.fn(async () => undefined);
|
||||
render(<MNewTripSheet open trip={null} onClose={() => {}} onSave={onSave} />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Summer in Japan'), { target: { value: 'Iceland' } });
|
||||
fireEvent.change(screen.getByLabelText('Start Date'), { target: { value: '2026-06-10' } });
|
||||
fireEvent.change(screen.getByLabelText('End Date'), { target: { value: '2026-06-01' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create New Trip' }));
|
||||
|
||||
expect(screen.getByText('End date must be after start date')).toBeInTheDocument();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-005: a dateless create asks for the 7 default days and closes', async () => {
|
||||
const onSave = vi.fn(async (_data: TripCreateRequest) => undefined);
|
||||
const onClose = vi.fn();
|
||||
render(<MNewTripSheet open trip={null} onClose={onClose} onSave={onSave} />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Summer in Japan'), { target: { value: ' Iceland ' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('What is this trip about?'), { target: { value: ' ring road ' } });
|
||||
fireEvent.change(screen.getByLabelText('currency'), { target: { value: 'USD' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create New Trip' }));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith({
|
||||
title: 'Iceland',
|
||||
description: 'ring road',
|
||||
start_date: null,
|
||||
end_date: null,
|
||||
currency: 'USD',
|
||||
day_count: 7,
|
||||
}));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-006: moving the start keeps the trip length', () => {
|
||||
render(<MNewTripSheet open trip={buildDashTrip()} onClose={() => {}} onSave={() => {}} />);
|
||||
|
||||
// 01.–08.05. is a 7-day span; shifting the start by a month keeps it.
|
||||
fireEvent.change(screen.getByLabelText('Start Date'), { target: { value: '2026-06-01' } });
|
||||
|
||||
expect(screen.getByLabelText('End Date')).toHaveValue('2026-06-08');
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-007: a start without an end pulls the end date along', () => {
|
||||
render(<MNewTripSheet open trip={null} onClose={() => {}} onSave={() => {}} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Start Date'), { target: { value: '2026-06-01' } });
|
||||
|
||||
expect(screen.getByLabelText('End Date')).toHaveValue('2026-06-01');
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-008: a rejected save surfaces the error and keeps the sheet open', async () => {
|
||||
const onSave = vi.fn(async () => { throw new Error('Title already used'); });
|
||||
const onClose = vi.fn();
|
||||
render(<MNewTripSheet open trip={null} onClose={onClose} onSave={onSave} />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Summer in Japan'), { target: { value: 'Iceland' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create New Trip' }));
|
||||
|
||||
expect(await screen.findByText('Title already used')).toBeInTheDocument();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-009: create mode holds the picked file back until the trip exists', async () => {
|
||||
const upload = vi.spyOn(tripsApi, 'uploadCover').mockResolvedValue({ cover_image: '/uploads/covers/new.jpg' });
|
||||
const onCoverUpdate = vi.fn();
|
||||
const onSave = vi.fn(async () => ({ trip: { id: 77 } as Trip }));
|
||||
render(<MNewTripSheet open trip={null} onClose={() => {}} onSave={onSave} onCoverUpdate={onCoverUpdate} />);
|
||||
|
||||
fireEvent.change(fileInput(), { target: { files: [png()] } });
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument());
|
||||
// Nothing is uploaded before the trip is created.
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Summer in Japan'), { target: { value: 'Iceland' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create New Trip' }));
|
||||
|
||||
await waitFor(() => expect(upload).toHaveBeenCalledWith(77, expect.any(FormData)));
|
||||
expect(onCoverUpdate).toHaveBeenCalledWith(77, '/uploads/covers/new.jpg');
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-010: a failed post-create cover upload only toasts', async () => {
|
||||
vi.spyOn(tripsApi, 'uploadCover').mockRejectedValue(new Error('nope'));
|
||||
const onClose = vi.fn();
|
||||
render(<MNewTripSheet open trip={null} onClose={onClose} onSave={async () => ({ trip: { id: 77 } as Trip })} />);
|
||||
|
||||
fireEvent.change(fileInput(), { target: { files: [png()] } });
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument());
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Summer in Japan'), { target: { value: 'Iceland' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create New Trip' }));
|
||||
|
||||
await waitFor(() => expect(toastCalls).toContainEqual(['Failed to upload', 'error']));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-011: an ignored file change is a no-op', async () => {
|
||||
const upload = vi.spyOn(tripsApi, 'uploadCover');
|
||||
render(<MNewTripSheet open trip={buildDashTrip({ cover_image: null })} onClose={() => {}} onSave={() => {}} />);
|
||||
|
||||
fireEvent.change(fileInput(), { target: { files: [] } });
|
||||
|
||||
await waitFor(() => expect(upload).not.toHaveBeenCalled());
|
||||
expect(screen.getByText('Add cover image')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-012: edit mode uploads the cover straight away', async () => {
|
||||
const upload = vi.spyOn(tripsApi, 'uploadCover').mockResolvedValue({ cover_image: '/uploads/covers/next.jpg' });
|
||||
const onCoverUpdate = vi.fn();
|
||||
render(<MNewTripSheet open trip={buildDashTrip()} onClose={() => {}} onSave={() => {}} onCoverUpdate={onCoverUpdate} />);
|
||||
|
||||
fireEvent.change(fileInput(), { target: { files: [png()] } });
|
||||
|
||||
await waitFor(() => expect(upload).toHaveBeenCalledWith(42, expect.any(FormData)));
|
||||
expect(onCoverUpdate).toHaveBeenCalledWith(42, '/uploads/covers/next.jpg');
|
||||
expect(toastCalls).toContainEqual(['Cover image saved', 'success']);
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-013: a failed edit-mode upload toasts and keeps the old cover', async () => {
|
||||
vi.spyOn(tripsApi, 'uploadCover').mockRejectedValue(new Error('boom'));
|
||||
render(<MNewTripSheet open trip={buildDashTrip()} onClose={() => {}} onSave={() => {}} />);
|
||||
|
||||
fireEvent.change(fileInput(), { target: { files: [png()] } });
|
||||
|
||||
await waitFor(() => expect(toastCalls).toContainEqual(['Failed to upload', 'error']));
|
||||
expect(document.querySelector('img[src="/uploads/covers/a.jpg"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-014: searching with neither a query nor a title asks for a term', async () => {
|
||||
const search = vi.spyOn(tripsApi, 'searchCoverImages');
|
||||
render(<MNewTripSheet open trip={null} onClose={() => {}} onSave={() => {}} />);
|
||||
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('Search destination photos'), { key: 'Enter' });
|
||||
|
||||
expect(await screen.findByText('Enter a search term')).toBeInTheDocument();
|
||||
expect(search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-015: an empty result set is reported as "no images"', async () => {
|
||||
vi.spyOn(tripsApi, 'searchCoverImages').mockResolvedValue({ photos: [] });
|
||||
render(<MNewTripSheet open trip={null} onClose={() => {}} onSave={() => {}} />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search destination photos'), { target: { value: 'reykjavik' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search Unsplash' }));
|
||||
|
||||
expect(await screen.findByText('No images found')).toBeInTheDocument();
|
||||
expect(tripsApi.searchCoverImages).toHaveBeenCalledWith('reykjavik');
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-016: a failing search shows the API error message', async () => {
|
||||
vi.spyOn(tripsApi, 'searchCoverImages').mockRejectedValue({ response: { data: { error: 'Unsplash key missing' } } });
|
||||
render(<MNewTripSheet open trip={null} onClose={() => {}} onSave={() => {}} />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Summer in Japan'), { target: { value: 'Iceland' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search Unsplash' }));
|
||||
|
||||
expect(await screen.findByText('Unsplash key missing')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-017: create mode previews the picked Unsplash photo without a request', async () => {
|
||||
vi.spyOn(tripsApi, 'searchCoverImages').mockResolvedValue({
|
||||
photos: [{ id: 'p1', url: 'https://img/full.jpg', thumb: 'https://img/thumb.jpg', photographer: 'Ada' }],
|
||||
});
|
||||
const update = vi.spyOn(tripsApi, 'update');
|
||||
render(<MNewTripSheet open trip={null} onClose={() => {}} onSave={() => {}} />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search destination photos'), { target: { value: 'iceland' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search Unsplash' }));
|
||||
|
||||
const pick = await screen.findByRole('button', { name: 'Use Unsplash photo by Ada' });
|
||||
expect(screen.getByText('Ada')).toBeInTheDocument();
|
||||
fireEvent.click(pick);
|
||||
|
||||
await waitFor(() => expect(document.querySelector('img[src="https://img/full.jpg"]')).toBeInTheDocument());
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-018: a pending Unsplash pick is written to the freshly created trip', async () => {
|
||||
vi.spyOn(tripsApi, 'searchCoverImages').mockResolvedValue({
|
||||
photos: [{ id: 'p1', url: 'https://img/full.jpg', thumb: 'https://img/thumb.jpg', photographer: null }],
|
||||
});
|
||||
const update = vi.spyOn(tripsApi, 'update').mockResolvedValue({ trip: { id: 77 } as Trip });
|
||||
const onCoverUpdate = vi.fn();
|
||||
render(
|
||||
<MNewTripSheet
|
||||
open
|
||||
trip={null}
|
||||
onClose={() => {}}
|
||||
onSave={async () => ({ trip: { id: 77 } as Trip })}
|
||||
onCoverUpdate={onCoverUpdate}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Summer in Japan'), { target: { value: 'Iceland' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search Unsplash' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Use Unsplash photo by Unsplash' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create New Trip' }));
|
||||
|
||||
await waitFor(() => expect(update).toHaveBeenCalledWith(77, { cover_image: 'https://img/full.jpg' }));
|
||||
expect(onCoverUpdate).toHaveBeenCalledWith(77, 'https://img/full.jpg');
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-019: a failed cover write after create only toasts', async () => {
|
||||
vi.spyOn(tripsApi, 'searchCoverImages').mockResolvedValue({
|
||||
photos: [{ id: 'p1', url: 'https://img/full.jpg', thumb: 'https://img/thumb.jpg', photographer: null }],
|
||||
});
|
||||
vi.spyOn(tripsApi, 'update').mockRejectedValue(new Error('offline'));
|
||||
render(<MNewTripSheet open trip={null} onClose={() => {}} onSave={async () => ({ trip: { id: 77 } as Trip })} />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Summer in Japan'), { target: { value: 'Iceland' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search Unsplash' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Use Unsplash photo by Unsplash' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create New Trip' }));
|
||||
|
||||
await waitFor(() => expect(toastCalls).toContainEqual(['Failed to save cover image', 'error']));
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-020: edit mode persists an Unsplash pick immediately', async () => {
|
||||
vi.spyOn(tripsApi, 'searchCoverImages').mockResolvedValue({
|
||||
photos: [{ id: 'p1', url: 'https://img/full.jpg', thumb: 'https://img/thumb.jpg', photographer: 'Ada' }],
|
||||
});
|
||||
const update = vi.spyOn(tripsApi, 'update').mockResolvedValue({ trip: { id: 42 } as Trip });
|
||||
render(<MNewTripSheet open trip={buildDashTrip()} onClose={() => {}} onSave={() => {}} />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search destination photos'), { target: { value: 'iceland' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search Unsplash' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Use Unsplash photo by Ada' }));
|
||||
|
||||
await waitFor(() => expect(update).toHaveBeenCalledWith(42, { cover_image: 'https://img/full.jpg' }));
|
||||
expect(toastCalls).toContainEqual(['Cover image saved', 'success']);
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-021: a failed edit-mode Unsplash pick toasts the API error', async () => {
|
||||
vi.spyOn(tripsApi, 'searchCoverImages').mockResolvedValue({
|
||||
photos: [{ id: 'p1', url: 'https://img/full.jpg', thumb: 'https://img/thumb.jpg', photographer: 'Ada' }],
|
||||
});
|
||||
vi.spyOn(tripsApi, 'update').mockRejectedValue({ response: { data: { error: 'Storage full' } } });
|
||||
render(<MNewTripSheet open trip={buildDashTrip()} onClose={() => {}} onSave={() => {}} />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search destination photos'), { target: { value: 'iceland' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search Unsplash' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Use Unsplash photo by Ada' }));
|
||||
|
||||
await waitFor(() => expect(toastCalls).toContainEqual(['Storage full', 'error']));
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-022: removing a pending cover needs no request', async () => {
|
||||
const update = vi.spyOn(tripsApi, 'update');
|
||||
render(<MNewTripSheet open trip={null} onClose={() => {}} onSave={() => {}} />);
|
||||
|
||||
fireEvent.change(fileInput(), { target: { files: [png()] } });
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Delete' }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Add cover image')).toBeInTheDocument());
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-023: removing a stored cover clears it on the server', async () => {
|
||||
const update = vi.spyOn(tripsApi, 'update').mockResolvedValue({ trip: { id: 42 } as Trip });
|
||||
const onCoverUpdate = vi.fn();
|
||||
render(<MNewTripSheet open trip={buildDashTrip()} onClose={() => {}} onSave={() => {}} onCoverUpdate={onCoverUpdate} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
|
||||
await waitFor(() => expect(update).toHaveBeenCalledWith(42, { cover_image: null }));
|
||||
expect(onCoverUpdate).toHaveBeenCalledWith(42, null);
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-024: a failed cover removal toasts and keeps the image', async () => {
|
||||
vi.spyOn(tripsApi, 'update').mockRejectedValue(new Error('nope'));
|
||||
render(<MNewTripSheet open trip={buildDashTrip()} onClose={() => {}} onSave={() => {}} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
|
||||
await waitFor(() => expect(toastCalls).toContainEqual(['Failed to remove', 'error']));
|
||||
expect(document.querySelector('img[src="/uploads/covers/a.jpg"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-025: archiving from the edit sheet runs the action and closes', () => {
|
||||
const onArchive = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
render(<MNewTripSheet open trip={buildDashTrip()} onClose={onClose} onSave={() => {}} onArchive={onArchive} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Archive'));
|
||||
|
||||
expect(onArchive).toHaveBeenCalledTimes(1);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-026: an archived trip offers restore instead of archive', () => {
|
||||
render(
|
||||
<MNewTripSheet open trip={buildDashTrip({ is_archived: 1 })} onClose={() => {}} onSave={() => {}} onArchive={() => {}} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Restore')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Archive')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-027: without trip_edit the text fields are read-only', () => {
|
||||
usePermissionsStore.setState({ permissions: { trip_edit: 'trip_owner' } });
|
||||
render(<MNewTripSheet open trip={buildDashTrip({ user_id: 999 })} onClose={() => {}} onSave={() => {}} />);
|
||||
|
||||
const title = screen.getByPlaceholderText('e.g. Summer in Japan');
|
||||
expect(title).toHaveAttribute('readonly');
|
||||
fireEvent.change(title, { target: { value: 'hijacked' } });
|
||||
expect(title).toHaveValue('Japan 2026');
|
||||
expect(screen.getByLabelText('currency')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-028: without trip_cover_upload the whole cover block is hidden', () => {
|
||||
usePermissionsStore.setState({ permissions: { trip_cover_upload: 'trip_owner' } });
|
||||
render(<MNewTripSheet open trip={buildDashTrip({ user_id: 999 })} onClose={() => {}} onSave={() => {}} />);
|
||||
|
||||
expect(screen.queryByPlaceholderText('Search destination photos')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Search Unsplash' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-030: only the newest search may apply its result', async () => {
|
||||
let resolveFirst: (v: { photos: CoverSearchPhoto[] }) => void = () => {};
|
||||
const search = vi.spyOn(tripsApi, 'searchCoverImages')
|
||||
.mockImplementationOnce(() => new Promise(res => { resolveFirst = res; }))
|
||||
.mockResolvedValueOnce({ photos: [{ id: 'b', url: 'u2', thumb: 't2', photographer: 'Second' }] });
|
||||
render(<MNewTripSheet open trip={null} onClose={() => {}} onSave={() => {}} />);
|
||||
const button = screen.getByRole('button', { name: 'Search Unsplash' });
|
||||
|
||||
const field = screen.getByPlaceholderText('Search destination photos');
|
||||
fireEvent.change(field, { target: { value: 'a' } });
|
||||
fireEvent.click(button);
|
||||
// The button disables itself while a search runs; Enter still starts the next one.
|
||||
fireEvent.keyDown(field, { key: 'Enter' });
|
||||
await screen.findByRole('button', { name: 'Use Unsplash photo by Second' });
|
||||
|
||||
// The stale first response must not replace the newer results.
|
||||
await act(async () => { resolveFirst({ photos: [{ id: 'a', url: 'u1', thumb: 't1', photographer: 'First' }] }); });
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Use Unsplash photo by First' })).not.toBeInTheDocument();
|
||||
expect(search).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-031: a stale search failure does not overwrite the newer results', async () => {
|
||||
let rejectFirst: (e: unknown) => void = () => {};
|
||||
vi.spyOn(tripsApi, 'searchCoverImages')
|
||||
.mockImplementationOnce(() => new Promise((_res, rej) => { rejectFirst = rej; }))
|
||||
.mockResolvedValueOnce({ photos: [{ id: 'b', url: 'u2', thumb: 't2', photographer: 'Second' }] });
|
||||
render(<MNewTripSheet open trip={null} onClose={() => {}} onSave={() => {}} />);
|
||||
const button = screen.getByRole('button', { name: 'Search Unsplash' });
|
||||
|
||||
const field = screen.getByPlaceholderText('Search destination photos');
|
||||
fireEvent.change(field, { target: { value: 'a' } });
|
||||
fireEvent.click(button);
|
||||
fireEvent.keyDown(field, { key: 'Enter' });
|
||||
await screen.findByRole('button', { name: 'Use Unsplash photo by Second' });
|
||||
|
||||
await act(async () => { rejectFirst(new Error('too late')); });
|
||||
|
||||
expect(screen.queryByText('too late')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Use Unsplash photo by Second' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-032: a result without a full-size url cannot be picked', async () => {
|
||||
vi.spyOn(tripsApi, 'searchCoverImages').mockResolvedValue({
|
||||
photos: [{ id: 'p1', url: '', thumb: 'https://img/thumb.jpg', photographer: 'Ada' }],
|
||||
});
|
||||
const update = vi.spyOn(tripsApi, 'update');
|
||||
render(<MNewTripSheet open trip={buildDashTrip({ cover_image: null })} onClose={() => {}} onSave={() => {}} />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search destination photos'), { target: { value: 'iceland' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search Unsplash' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Use Unsplash photo by Ada' }));
|
||||
|
||||
await waitFor(() => expect(update).not.toHaveBeenCalled());
|
||||
expect(screen.getByText('Add cover image')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-033: the cover buttons open the device file picker', async () => {
|
||||
const click = vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(() => {});
|
||||
render(<MNewTripSheet open trip={null} onClose={() => {}} onSave={() => {}} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Add cover image'));
|
||||
expect(click).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.change(fileInput(), { target: { files: [png()] } });
|
||||
fireEvent.click(await screen.findByText('Change'));
|
||||
expect(click).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-029: the cancel actions close without saving', () => {
|
||||
const onClose = vi.fn();
|
||||
const onSave = vi.fn(async () => undefined);
|
||||
render(<MNewTripSheet open trip={null} onClose={onClose} onSave={onSave} />);
|
||||
|
||||
// Both the header X and the footer button carry the cancel label.
|
||||
const cancels = screen.getAllByRole('button', { name: 'Cancel' });
|
||||
expect(cancels).toHaveLength(2);
|
||||
cancels.forEach(btn => fireEvent.click(btn));
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(2);
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -51,4 +51,59 @@ describe('MUserMenu', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Color Mode/ }));
|
||||
expect(updateSetting).toHaveBeenCalledWith('dark_mode', 'auto');
|
||||
});
|
||||
|
||||
it('FE-MOB-MENU-004: auto cycles back to dark', () => {
|
||||
const updateSetting = vi.fn(async () => {});
|
||||
useSettingsStore.setState({ updateSetting });
|
||||
useSettingsStore.setState(s => ({ settings: { ...s.settings, dark_mode: 'auto' } }));
|
||||
|
||||
render(<MUserMenu open onClose={() => {}} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /Color Mode/ }));
|
||||
|
||||
expect(updateSetting).toHaveBeenCalledWith('dark_mode', 'dark');
|
||||
});
|
||||
|
||||
it('FE-MOB-MENU-005: a legacy boolean dark_mode still resolves to dark', () => {
|
||||
const updateSetting = vi.fn(async () => {});
|
||||
useSettingsStore.setState({ updateSetting });
|
||||
useSettingsStore.setState(s => ({ settings: { ...s.settings, dark_mode: true } }));
|
||||
|
||||
render(<MUserMenu open onClose={() => {}} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /Color Mode/ }));
|
||||
|
||||
expect(updateSetting).toHaveBeenCalledWith('dark_mode', 'light');
|
||||
});
|
||||
|
||||
it('FE-MOB-MENU-006: opening settings closes the menu first', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<MUserMenu open onClose={onClose} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Settings' }));
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-MENU-007: signing out closes the menu and clears the session', async () => {
|
||||
const onClose = vi.fn();
|
||||
const logout = vi.fn(async () => {});
|
||||
useAuthStore.setState({ logout });
|
||||
render(<MUserMenu open onClose={onClose} />);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Logout' }));
|
||||
});
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(logout).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-MENU-008: an uploaded avatar replaces the initial', () => {
|
||||
useAuthStore.setState({
|
||||
isAuthenticated: true,
|
||||
user: { id: 1, username: 'Maurice', email: 'm@trek.app', role: 'user', avatar_url: '/uploads/avatars/m.jpg' } as never,
|
||||
});
|
||||
const { container } = render(<MUserMenu open onClose={() => {}} />);
|
||||
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', '/uploads/avatars/m.jpg');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// FE-MOB-JLIST-001 to FE-MOB-JLIST-012
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '../../../helpers/render';
|
||||
import MJourney from '../../../../src/mobile/screens/journey/MJourney';
|
||||
import type { Journey } from '../../../../src/store/journeyStore';
|
||||
|
||||
const mocks = vi.hoisted(() => ({ journey: {} as Record<string, unknown> }));
|
||||
|
||||
vi.mock('../../../../src/pages/journey/useJourney', () => ({
|
||||
useJourney: () => mocks.journey,
|
||||
}));
|
||||
|
||||
type ListItem = Journey & { entry_count?: number; photo_count?: number; place_count?: number };
|
||||
|
||||
function buildJourney(over: Partial<ListItem> = {}): ListItem {
|
||||
return {
|
||||
id: 1,
|
||||
user_id: 1,
|
||||
title: 'Japan 2026',
|
||||
subtitle: null,
|
||||
cover_gradient: null,
|
||||
cover_image: null,
|
||||
status: 'active',
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function buildHook(over: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
navigate: vi.fn(),
|
||||
journeys: [] as ListItem[],
|
||||
loading: false,
|
||||
showCreate: false,
|
||||
setShowCreate: vi.fn(),
|
||||
newTitle: '',
|
||||
setNewTitle: vi.fn(),
|
||||
availableTrips: [],
|
||||
selectedTripIds: new Set<number>(),
|
||||
setSelectedTripIds: vi.fn(),
|
||||
openCreateModal: vi.fn(),
|
||||
handleCreate: vi.fn(),
|
||||
activeJourney: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function setup(over: Record<string, unknown> = {}) {
|
||||
mocks.journey = buildHook(over);
|
||||
render(<MJourney />);
|
||||
return mocks.journey;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('MJourney', () => {
|
||||
it('FE-MOB-JLIST-001: shows a spinner while the first load is running', () => {
|
||||
setup({ loading: true, journeys: [] });
|
||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Other journeys')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JLIST-002: shows the mascot empty state when there are no journeys', () => {
|
||||
setup();
|
||||
expect(screen.getByText('Create a new Journey')).toBeInTheDocument();
|
||||
expect(document.querySelector('.animate-spin')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JLIST-003: renders the active journey as hero with its counters', () => {
|
||||
const hero = buildJourney({ id: 4, title: 'Norway', entry_count: 12, photo_count: 40, place_count: 7 });
|
||||
setup({ journeys: [hero], activeJourney: hero });
|
||||
|
||||
expect(screen.getByText('Latest journey')).toBeInTheDocument();
|
||||
expect(screen.getByText('Norway')).toBeInTheDocument();
|
||||
expect(screen.getByText('12 entries')).toBeInTheDocument();
|
||||
expect(screen.getByText('40 photos')).toBeInTheDocument();
|
||||
expect(screen.getByText('7 places')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JLIST-004: falls back to the first list entry when no journey is active', () => {
|
||||
const first = buildJourney({ id: 9, title: 'Fallback hero' });
|
||||
setup({ journeys: [first], activeJourney: null });
|
||||
expect(screen.getByText('Fallback hero')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JLIST-005: renders the hero cover image when one is set', () => {
|
||||
const hero = buildJourney({ id: 4, title: 'Norway', cover_image: 'covers/n.jpg' });
|
||||
setup({ journeys: [hero], activeJourney: hero });
|
||||
expect(document.querySelector('img')).toHaveAttribute('src', '/uploads/covers/n.jpg');
|
||||
});
|
||||
|
||||
it('FE-MOB-JLIST-006: opening the hero navigates to its detail route', () => {
|
||||
const hero = buildJourney({ id: 4, title: 'Norway' });
|
||||
const hook = setup({ journeys: [hero], activeJourney: hero });
|
||||
fireEvent.click(screen.getByText('Norway'));
|
||||
expect(hook.navigate).toHaveBeenCalledWith('/journey/4');
|
||||
});
|
||||
|
||||
it('FE-MOB-JLIST-007: lists the remaining journeys in the grid with their counters', () => {
|
||||
const hero = buildJourney({ id: 4, title: 'Norway' });
|
||||
const other = buildJourney({ id: 5, title: 'Iceland', entry_count: 3, photo_count: 0, place_count: 0 });
|
||||
setup({ journeys: [hero, other], activeJourney: hero });
|
||||
|
||||
expect(screen.getByText('Other journeys')).toBeInTheDocument();
|
||||
expect(screen.getByText('Iceland')).toBeInTheDocument();
|
||||
expect(screen.getByText('3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JLIST-008: hides the counter row on a grid card without any stats', () => {
|
||||
const hero = buildJourney({ id: 4, title: 'Norway' });
|
||||
const empty = buildJourney({ id: 5, title: 'Iceland' });
|
||||
setup({ journeys: [hero, empty], activeJourney: hero });
|
||||
|
||||
expect(screen.getByText('Iceland')).toBeInTheDocument();
|
||||
expect(screen.queryByText('0')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JLIST-009: opening a grid card navigates to that journey', () => {
|
||||
const hero = buildJourney({ id: 4, title: 'Norway' });
|
||||
const other = buildJourney({ id: 5, title: 'Iceland' });
|
||||
const hook = setup({ journeys: [hero, other], activeJourney: hero });
|
||||
fireEvent.click(screen.getByText('Iceland'));
|
||||
expect(hook.navigate).toHaveBeenCalledWith('/journey/5');
|
||||
});
|
||||
|
||||
it('FE-MOB-JLIST-010: the header pill opens the create modal', () => {
|
||||
const hook = setup();
|
||||
fireEvent.click(screen.getByRole('button', { name: /Create Journey/ }));
|
||||
expect(hook.openCreateModal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-JLIST-011: toggling a trip in the create sheet adds and removes the id', () => {
|
||||
const trips = [{ id: 21, title: 'Tokyo', start_date: '2026-05-01', end_date: '2026-05-04', place_count: 3 }];
|
||||
const hook = setup({ showCreate: true, newTitle: 'Japan', availableTrips: trips });
|
||||
|
||||
fireEvent.click(screen.getByText('Tokyo'));
|
||||
const setSelected = hook.setSelectedTripIds as ReturnType<typeof vi.fn>;
|
||||
const updater = setSelected.mock.calls[0][0] as (prev: Set<number>) => Set<number>;
|
||||
expect([...updater(new Set<number>())]).toEqual([21]);
|
||||
expect([...updater(new Set<number>([21]))]).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-MOB-JLIST-012: closing the create sheet clears the flag on the hook', () => {
|
||||
const hook = setup({ showCreate: true, newTitle: 'Japan' });
|
||||
const cancels = screen.getAllByRole('button', { name: 'Cancel' });
|
||||
fireEvent.click(cancels[cancels.length - 1]);
|
||||
expect(hook.setShowCreate).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,561 @@
|
||||
// FE-MOB-JDET-001 to FE-MOB-JDET-031
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '../../../helpers/render';
|
||||
import MJourneyDetail from '../../../../src/mobile/screens/journey/MJourneyDetail';
|
||||
import { addonsApi, journeyApi } from '../../../../src/api/client';
|
||||
import { useJourneyStore } from '../../../../src/store/journeyStore';
|
||||
import { useAuthStore } from '../../../../src/store/authStore';
|
||||
import type { GalleryPhoto, JourneyDetail, JourneyEntry } from '../../../../src/store/journeyStore';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
detail: {} as Record<string, unknown>,
|
||||
focusMarker: vi.fn(),
|
||||
highlightMarker: vi.fn(),
|
||||
captured: {} as Record<string, Record<string, unknown>>,
|
||||
}));
|
||||
|
||||
vi.mock('../../../../src/pages/journeyDetail/useJourneyDetail', () => ({
|
||||
useJourneyDetail: () => mocks.detail,
|
||||
}));
|
||||
|
||||
vi.mock('../../../../src/components/Journey/JourneyMapAuto', async () => {
|
||||
const React = await import('react');
|
||||
const Comp = React.forwardRef((props: Record<string, unknown>, ref: React.Ref<unknown>) => {
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
focusMarker: mocks.focusMarker,
|
||||
highlightMarker: mocks.highlightMarker,
|
||||
invalidateSize: vi.fn(),
|
||||
}));
|
||||
const entries = props.entries as { id: string }[];
|
||||
const onMarkerClick = props.onMarkerClick as ((id: string) => void) | undefined;
|
||||
return (
|
||||
<div data-testid="journey-map" data-active={String(props.activeMarkerId ?? '')} data-dark={String(props.dark)}>
|
||||
{entries.map(e => (
|
||||
<button key={e.id} type="button" onClick={() => onMarkerClick?.(e.id)}>{`marker-${e.id}`}</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
Comp.displayName = 'MockJourneyMapAuto';
|
||||
return { __esModule: true, default: Comp };
|
||||
});
|
||||
|
||||
vi.mock('../../../../src/components/Journey/PhotoLightbox', () => ({
|
||||
default: (props: Record<string, unknown>) => {
|
||||
mocks.captured.lightbox = props;
|
||||
return <div data-testid="lightbox" />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../../src/components/Journey/ContributorInviteDialog', () => ({
|
||||
default: (props: Record<string, unknown>) => {
|
||||
mocks.captured.invite = props;
|
||||
return <div data-testid="invite-dialog" />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../../src/components/Journey/JourneyDetailPageProviderPicker', () => ({
|
||||
ProviderPicker: (props: Record<string, unknown>) => {
|
||||
mocks.captured.picker = props;
|
||||
return <div data-testid="provider-picker" />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../../src/mobile/screens/journey/MJourneyEntrySheet', () => ({
|
||||
default: (props: Record<string, unknown>) => {
|
||||
mocks.captured.entrySheet = props;
|
||||
return <div data-testid="entry-sheet" data-quick={String(props.quickCapture)} data-readonly={String(props.readOnly)} />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../../src/mobile/screens/journey/MJourneySettingsSheet', () => ({
|
||||
default: (props: Record<string, unknown>) => {
|
||||
mocks.captured.settings = props;
|
||||
return <div data-testid="settings-sheet" />;
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function buildEntry(over: Partial<JourneyEntry> = {}): JourneyEntry {
|
||||
return {
|
||||
id: 1,
|
||||
journey_id: 12,
|
||||
author_id: 1,
|
||||
type: 'entry',
|
||||
title: 'Arrival',
|
||||
story: null,
|
||||
entry_date: '2026-05-01',
|
||||
entry_time: '10:00',
|
||||
location_name: 'Tokyo',
|
||||
location_lat: 35.6,
|
||||
location_lng: 139.7,
|
||||
mood: null,
|
||||
weather: null,
|
||||
tags: [],
|
||||
pros_cons: null,
|
||||
visibility: 'private',
|
||||
sort_order: 0,
|
||||
photos: [],
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function buildGalleryPhoto(over: Partial<GalleryPhoto> = {}): GalleryPhoto {
|
||||
return {
|
||||
id: 501,
|
||||
journey_id: 12,
|
||||
photo_id: 900,
|
||||
caption: 'Shrine',
|
||||
shared: 1,
|
||||
sort_order: 0,
|
||||
created_at: 0,
|
||||
provider: 'local',
|
||||
asset_id: null,
|
||||
owner_id: 1,
|
||||
file_path: null,
|
||||
thumbnail_path: 'thumbs/900.jpg',
|
||||
media_type: 'image',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDetail(over: Partial<JourneyDetail> = {}): JourneyDetail {
|
||||
return {
|
||||
id: 12,
|
||||
user_id: 1,
|
||||
title: 'Japan 2026',
|
||||
subtitle: null,
|
||||
cover_gradient: null,
|
||||
cover_image: null,
|
||||
status: 'active',
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
entries: [buildEntry(), buildEntry({ id: 2, title: 'Kyoto', entry_date: '2026-05-02' })],
|
||||
gallery: [],
|
||||
trips: [],
|
||||
contributors: [{ journey_id: 12, user_id: 5, role: 'owner', added_at: 0, username: 'maurice', avatar: null }],
|
||||
stats: { entries: 2, photos: 0, places: 0 },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() };
|
||||
|
||||
function buildHook(over: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
const current = (over.current as JourneyDetail | null | undefined) ?? buildDetail();
|
||||
return {
|
||||
id: '12',
|
||||
navigate: vi.fn(),
|
||||
toast,
|
||||
t: (key: string) => key,
|
||||
current,
|
||||
loading: false,
|
||||
canEditEntries: true,
|
||||
canEditJourney: true,
|
||||
view: 'timeline',
|
||||
setView: vi.fn(),
|
||||
editingEntry: null,
|
||||
setEditingEntry: vi.fn(),
|
||||
lightbox: null,
|
||||
setLightbox: vi.fn(),
|
||||
deleteTarget: null,
|
||||
setDeleteTarget: vi.fn(),
|
||||
showInvite: false,
|
||||
setShowInvite: vi.fn(),
|
||||
showSettings: false,
|
||||
setShowSettings: vi.fn(),
|
||||
hideSkeletons: false,
|
||||
sidebarMapItems: (current?.entries ?? []).map(e => ({ id: String(e.id), lat: 1, lng: 2, title: e.title ?? '' })),
|
||||
loadJourney: vi.fn(),
|
||||
updateEntry: vi.fn(async () => {}),
|
||||
deleteEntry: vi.fn(async () => {}),
|
||||
uploadPhotos: vi.fn(async () => ({ succeeded: [], failed: [] })),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function setup(over: Record<string, unknown> = {}) {
|
||||
mocks.detail = buildHook(over);
|
||||
const view = render(<MJourneyDetail />);
|
||||
return { ...view, hook: mocks.detail };
|
||||
}
|
||||
|
||||
const journeyStoreInitial = useJourneyStore.getState();
|
||||
let uploadGalleryPhotos: ReturnType<typeof vi.fn>;
|
||||
let createEntry: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.captured = {};
|
||||
uploadGalleryPhotos = vi.fn(async () => ({ succeeded: [], failed: [] }));
|
||||
createEntry = vi.fn(async () => buildEntry({ id: 77 }));
|
||||
useJourneyStore.setState({ ...journeyStoreInitial, uploadGalleryPhotos, createEntry } as never, true);
|
||||
useAuthStore.setState({ user: { id: 5, username: 'maurice' } } as never);
|
||||
vi.spyOn(addonsApi, 'enabled').mockResolvedValue({ addons: [] });
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 200 })));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
useJourneyStore.setState(journeyStoreInitial, true);
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('MJourneyDetail', () => {
|
||||
it('FE-MOB-JDET-001: shows a spinner while the journey is loading', () => {
|
||||
setup({ loading: true, current: null });
|
||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('journey-map')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-002: renders one timeline card per entry with the map behind it', () => {
|
||||
setup();
|
||||
expect(screen.getByTestId('journey-map')).toBeInTheDocument();
|
||||
expect(screen.getByText('Arrival')).toBeInTheDocument();
|
||||
expect(screen.getByText('Kyoto')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('journey-map')).toHaveAttribute('data-active', '1');
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-003: hideSkeletons drops the trip-derived skeleton entries', () => {
|
||||
const current = buildDetail({ entries: [buildEntry(), buildEntry({ id: 3, type: 'skeleton', title: 'Suggested' })] });
|
||||
setup({ current, hideSkeletons: true });
|
||||
expect(screen.getByText('Arrival')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Suggested')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-004: tapping the centred card opens the entry sheet', () => {
|
||||
const { hook } = setup();
|
||||
fireEvent.click(screen.getByText('Arrival'));
|
||||
expect(hook.setEditingEntry).toHaveBeenCalledWith(expect.objectContaining({ id: 1 }));
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-005: tapping an off-centre card focuses it on the map instead of opening it', () => {
|
||||
const { hook } = setup();
|
||||
fireEvent.click(screen.getByText('Kyoto'));
|
||||
expect(hook.setEditingEntry).not.toHaveBeenCalled();
|
||||
expect(mocks.focusMarker).toHaveBeenCalledWith('2');
|
||||
expect(screen.getByTestId('journey-map')).toHaveAttribute('data-active', '2');
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-006: clicking a map marker centres the matching card', () => {
|
||||
setup();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'marker-2' }));
|
||||
expect(screen.getByTestId('journey-map')).toHaveAttribute('data-active', '2');
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-007: a marker without a matching entry leaves the selection alone', () => {
|
||||
const current = buildDetail();
|
||||
setup({
|
||||
current,
|
||||
sidebarMapItems: [{ id: '99', lat: 1, lng: 2, title: 'orphan' }],
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'marker-99' }));
|
||||
expect(screen.getByTestId('journey-map')).toHaveAttribute('data-active', '1');
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-008: an entry with no marker clears the highlight when it is centred', () => {
|
||||
const current = buildDetail();
|
||||
setup({ current, sidebarMapItems: [{ id: '1', lat: 1, lng: 2, title: 'Arrival' }] });
|
||||
fireEvent.click(screen.getByText('Kyoto'));
|
||||
expect(mocks.highlightMarker).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-009: the segment control switches between the journey and gallery tabs', () => {
|
||||
const { hook } = setup();
|
||||
fireEvent.click(screen.getByRole('button', { name: /journey.share.gallery/ }));
|
||||
expect(hook.setView).toHaveBeenCalledWith('gallery');
|
||||
fireEvent.click(screen.getByRole('button', { name: /journey.detail.journeyTab/ }));
|
||||
expect(hook.setView).toHaveBeenCalledWith('timeline');
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-010: back navigates to the journey list', () => {
|
||||
const { hook } = setup();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'journey.detail.backToJourney' }));
|
||||
expect(hook.navigate).toHaveBeenCalledWith('/journey');
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-011: the gallery tab shows the empty state when there are no photos', () => {
|
||||
setup({ view: 'gallery' });
|
||||
expect(screen.getByText('journey.detail.noPhotos')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-012: gallery photos open the lightbox at the tapped index', () => {
|
||||
const gallery = [buildGalleryPhoto(), buildGalleryPhoto({ id: 502, photo_id: 901, caption: null })];
|
||||
const { hook } = setup({ view: 'gallery', current: buildDetail({ gallery }) });
|
||||
|
||||
const tiles = screen.getAllByRole('button').filter(b => b.querySelector('img'));
|
||||
fireEvent.click(tiles[1]);
|
||||
expect(hook.setLightbox).toHaveBeenCalledWith({
|
||||
index: 1,
|
||||
photos: [
|
||||
expect.objectContaining({ id: 501, src: '/api/photos/900/original', caption: 'Shrine' }),
|
||||
expect.objectContaining({ id: 502, src: '/api/photos/901/original', caption: null }),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-013: a video without a poster renders a placeholder tile plus the play badge', () => {
|
||||
const gallery = [buildGalleryPhoto({ media_type: 'video', thumbnail_path: null })];
|
||||
setup({ view: 'gallery', current: buildDetail({ gallery }) });
|
||||
expect(document.querySelector('img')).not.toBeInTheDocument();
|
||||
expect(document.querySelector('.absolute.inset-0.flex.items-center')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-014: uploading from the device sends the picked files and reloads', async () => {
|
||||
const { hook, container } = setup({ view: 'gallery' });
|
||||
const input = container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const file = new File(['x'], 'p.jpg', { type: 'image/jpeg' });
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
|
||||
await waitFor(() => expect(uploadGalleryPhotos).toHaveBeenCalledWith(12, [file]));
|
||||
expect(toast.success).toHaveBeenCalledWith('journey.photosUploaded');
|
||||
expect(hook.loadJourney).toHaveBeenCalledWith(12);
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-015: a partially failed upload reports the failure count', async () => {
|
||||
const failed = new File(['x'], 'bad.jpg', { type: 'image/jpeg' });
|
||||
uploadGalleryPhotos.mockResolvedValueOnce({ succeeded: [], failed: [failed] });
|
||||
const { container } = setup({ view: 'gallery' });
|
||||
const input = container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { files: [failed] } });
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('journey.editor.uploadPartialFailed'));
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-016: a rejected upload surfaces the API error', async () => {
|
||||
uploadGalleryPhotos.mockRejectedValueOnce(new Error('disk full'));
|
||||
const { container } = setup({ view: 'gallery' });
|
||||
const input = container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { files: [new File(['x'], 'p.jpg', { type: 'image/jpeg' })] } });
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('disk full'));
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-017: an empty file selection does not upload', () => {
|
||||
const { container } = setup({ view: 'gallery' });
|
||||
const input = container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { files: [] } });
|
||||
expect(uploadGalleryPhotos).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-018: a connected photo provider turns the upload button into a source chooser', async () => {
|
||||
vi.mocked(addonsApi.enabled).mockResolvedValue({
|
||||
addons: [
|
||||
{ id: 'immich', name: 'Immich', type: 'photo_provider', enabled: true },
|
||||
{ id: 'legacy', name: 'Legacy', type: 'other', enabled: true },
|
||||
],
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ connected: true }), { status: 200 })));
|
||||
setup({ view: 'gallery' });
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'common.upload' }));
|
||||
expect(await screen.findByText('mobileJourney.uploadFromDevice')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('mobileJourney.browseProvider'));
|
||||
expect(await screen.findByTestId('provider-picker')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-019: adding provider photos reports the count and reloads', async () => {
|
||||
vi.mocked(addonsApi.enabled).mockResolvedValue({
|
||||
addons: [{ id: 'immich', name: 'Immich', type: 'photo_provider', enabled: true }],
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ connected: true }), { status: 200 })));
|
||||
const toGallery = vi.spyOn(journeyApi, 'addProviderPhotosToGallery').mockResolvedValue({ added: 3 });
|
||||
const toEntry = vi.spyOn(journeyApi, 'addProviderPhotos').mockResolvedValue({ added: 1 });
|
||||
const { hook } = setup({ view: 'gallery' });
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'common.upload' }));
|
||||
fireEvent.click(screen.getByText('mobileJourney.browseProvider'));
|
||||
await screen.findByTestId('provider-picker');
|
||||
|
||||
const onAdd = mocks.captured.picker.onAdd as (g: { assetIds: string[] }[], entryId?: number) => Promise<void>;
|
||||
await onAdd([{ assetIds: ['a1'] }], undefined);
|
||||
expect(toGallery).toHaveBeenCalledWith(12, 'immich', ['a1'], undefined, undefined);
|
||||
expect(toast.success).toHaveBeenCalledWith('journey.photosAdded');
|
||||
expect(hook.loadJourney).toHaveBeenCalledWith(12);
|
||||
|
||||
await onAdd([{ assetIds: ['a2'] }], 1);
|
||||
expect(toEntry).toHaveBeenCalledWith(1, 'immich', ['a2'], undefined, undefined, undefined);
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-020: a failed provider add reports the error instead of a success', async () => {
|
||||
vi.mocked(addonsApi.enabled).mockResolvedValue({
|
||||
addons: [{ id: 'immich', name: 'Immich', type: 'photo_provider', enabled: true }],
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ connected: true }), { status: 200 })));
|
||||
vi.spyOn(journeyApi, 'addProviderPhotosToGallery').mockRejectedValue(new Error('boom'));
|
||||
setup({ view: 'gallery' });
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'common.upload' }));
|
||||
fireEvent.click(screen.getByText('mobileJourney.browseProvider'));
|
||||
await screen.findByTestId('provider-picker');
|
||||
|
||||
const onAdd = mocks.captured.picker.onAdd as (g: { assetIds: string[] }[], entryId?: number) => Promise<void>;
|
||||
await onAdd([{ assetIds: ['a1'] }], undefined);
|
||||
expect(toast.error).toHaveBeenCalledWith('common.error');
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-021: the entry sheet creates a new entry in quick-capture mode', async () => {
|
||||
const { hook } = setup({ editingEntry: buildEntry({ id: 0, title: null }) });
|
||||
const sheet = mocks.captured.entrySheet;
|
||||
expect(screen.getByTestId('entry-sheet')).toHaveAttribute('data-quick', 'true');
|
||||
expect(sheet.onDelete).toBeUndefined();
|
||||
|
||||
const onSave = sheet.onSave as (d: Record<string, unknown>) => Promise<number>;
|
||||
await expect(onSave({ title: 'Fresh' })).resolves.toBe(77);
|
||||
expect(createEntry).toHaveBeenCalledWith(12, { title: 'Fresh' });
|
||||
|
||||
(sheet.onDone as () => void)();
|
||||
expect(hook.setEditingEntry).toHaveBeenCalledWith(null);
|
||||
expect(hook.loadJourney).toHaveBeenCalledWith(12);
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-022: editing an existing entry updates it and can hand off to the delete confirm', async () => {
|
||||
const entry = buildEntry({ id: 4, title: 'Old' });
|
||||
const { hook } = setup({ editingEntry: entry });
|
||||
const sheet = mocks.captured.entrySheet;
|
||||
|
||||
const onSave = sheet.onSave as (d: Record<string, unknown>) => Promise<number>;
|
||||
await expect(onSave({ title: 'New' })).resolves.toBe(4);
|
||||
expect(hook.updateEntry).toHaveBeenCalledWith(4, { title: 'New' });
|
||||
|
||||
(sheet.onDelete as () => void)();
|
||||
expect(hook.setEditingEntry).toHaveBeenCalledWith(null);
|
||||
expect(hook.setDeleteTarget).toHaveBeenCalledWith(entry);
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-023: a read-only viewer gets the sheet without a delete action', () => {
|
||||
setup({ canEditEntries: false, editingEntry: buildEntry({ id: 4 }) });
|
||||
expect(screen.getByTestId('entry-sheet')).toHaveAttribute('data-readonly', 'true');
|
||||
expect(mocks.captured.entrySheet.onDelete).toBeUndefined();
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-024: confirming the delete removes the entry and reloads', async () => {
|
||||
const target = buildEntry({ id: 4, title: 'Old' });
|
||||
const { hook } = setup({ deleteTarget: target });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }));
|
||||
|
||||
await waitFor(() => expect(hook.deleteEntry).toHaveBeenCalledWith(4));
|
||||
expect(hook.setDeleteTarget).toHaveBeenCalledWith(null);
|
||||
expect(hook.loadJourney).toHaveBeenCalledWith(12);
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-025: settings and invite dialogs wire their callbacks back into the hook', () => {
|
||||
const { hook } = setup({ showSettings: true, showInvite: true });
|
||||
|
||||
expect(screen.getByTestId('settings-sheet')).toBeInTheDocument();
|
||||
(mocks.captured.settings.onSaved as () => void)();
|
||||
expect(hook.setShowSettings).toHaveBeenCalledWith(false);
|
||||
(mocks.captured.settings.onOpenInvite as () => void)();
|
||||
expect(hook.setShowInvite).toHaveBeenCalledWith(true);
|
||||
(mocks.captured.settings.onRefresh as () => void)();
|
||||
|
||||
expect(screen.getByTestId('invite-dialog')).toBeInTheDocument();
|
||||
expect(mocks.captured.invite.existingUserIds).toEqual([5]);
|
||||
(mocks.captured.invite.onInvited as () => void)();
|
||||
expect(hook.setShowInvite).toHaveBeenCalledWith(false);
|
||||
expect(hook.loadJourney).toHaveBeenCalledWith(12);
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-026: the settings button is hidden for a non-owner and shown for the owner', () => {
|
||||
const { hook, unmount } = setup();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'journey.settings.title' }));
|
||||
expect(hook.setShowSettings).toHaveBeenCalledWith(true);
|
||||
unmount();
|
||||
|
||||
setup({ canEditJourney: false });
|
||||
expect(screen.queryByRole('button', { name: 'journey.settings.title' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-027: the lightbox renders the mapped photos and closes back into the hook', () => {
|
||||
const { hook } = setup({
|
||||
lightbox: { photos: [{ id: 501, src: '/api/photos/900/original', caption: 'Shrine', mediaType: 'image' }], index: 0 },
|
||||
});
|
||||
expect(screen.getByTestId('lightbox')).toBeInTheDocument();
|
||||
expect(mocks.captured.lightbox.photos).toEqual([
|
||||
expect.objectContaining({ id: '501', src: '/api/photos/900/original', caption: 'Shrine' }),
|
||||
]);
|
||||
(mocks.captured.lightbox.onClose as () => void)();
|
||||
expect(hook.setLightbox).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-028: a settled carousel scroll re-centres the map on the nearest card', async () => {
|
||||
setup();
|
||||
// Let the photo-provider probe land first: its state update re-runs the
|
||||
// carousel effect and would drop the pending scroll-settle timer.
|
||||
await waitFor(() => expect(addonsApi.enabled).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText('Kyoto'));
|
||||
expect(screen.getByTestId('journey-map')).toHaveAttribute('data-active', '2');
|
||||
|
||||
const carousel = document.querySelector('.overflow-x-auto') as HTMLElement;
|
||||
// two bursts in a row: only the last one may settle
|
||||
fireEvent.scroll(carousel);
|
||||
fireEvent.scroll(carousel);
|
||||
await waitFor(() => expect(screen.getByTestId('journey-map')).toHaveAttribute('data-active', '1'));
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-029: the device row of the upload chooser opens the file picker', async () => {
|
||||
vi.mocked(addonsApi.enabled).mockResolvedValue({
|
||||
addons: [{ id: 'immich', name: 'Immich', type: 'photo_provider', enabled: true }],
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ connected: true }), { status: 200 })));
|
||||
const { container } = setup({ view: 'gallery' });
|
||||
const input = container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const click = vi.spyOn(input, 'click').mockImplementation(() => {});
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'common.upload' }));
|
||||
fireEvent.click(await screen.findByText('mobileJourney.uploadFromDevice'));
|
||||
expect(click).toHaveBeenCalled();
|
||||
await waitFor(() => expect(screen.queryByText('mobileJourney.uploadFromDevice')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-030: a provider that reports itself as disconnected stays hidden', async () => {
|
||||
vi.mocked(addonsApi.enabled).mockResolvedValue({
|
||||
addons: [{ id: 'immich', name: 'Immich', type: 'photo_provider', enabled: true }],
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ connected: false }), { status: 200 })));
|
||||
const { container } = setup({ view: 'gallery' });
|
||||
const input = container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const click = vi.spyOn(input, 'click').mockImplementation(() => {});
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'common.upload' }));
|
||||
expect(click).toHaveBeenCalled();
|
||||
expect(screen.queryByText('mobileJourney.uploadFromDevice')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-031: closing the entry sheet, settings, invite and picker resets the hook state', async () => {
|
||||
vi.mocked(addonsApi.enabled).mockResolvedValue({
|
||||
addons: [{ id: 'immich', name: 'Immich', type: 'photo_provider', enabled: true }],
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ connected: true }), { status: 200 })));
|
||||
const gallery = [buildGalleryPhoto({ id: 601, asset_id: 'asset-1' }), buildGalleryPhoto({ id: 602, asset_id: null })];
|
||||
const { hook } = setup({
|
||||
view: 'gallery',
|
||||
current: buildDetail({ gallery }),
|
||||
editingEntry: buildEntry({ id: 4 }),
|
||||
showSettings: true,
|
||||
showInvite: true,
|
||||
});
|
||||
|
||||
(mocks.captured.entrySheet.onClose as () => void)();
|
||||
expect(hook.setEditingEntry).toHaveBeenCalledWith(null);
|
||||
(mocks.captured.settings.onClose as () => void)();
|
||||
expect(hook.setShowSettings).toHaveBeenCalledWith(false);
|
||||
(mocks.captured.invite.onClose as () => void)();
|
||||
expect(hook.setShowInvite).toHaveBeenCalledWith(false);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'common.upload' }));
|
||||
fireEvent.click(screen.getByText('mobileJourney.browseProvider'));
|
||||
await screen.findByTestId('provider-picker');
|
||||
expect([...(mocks.captured.picker.existingAssetIds as Set<string>)]).toEqual(['asset-1']);
|
||||
|
||||
(mocks.captured.picker.onClose as () => void)();
|
||||
await waitFor(() => expect(screen.queryByTestId('provider-picker')).not.toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,12 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { mapsApi, weatherApi } from '../../../../src/api/client';
|
||||
import MJourneyEntrySheet from '../../../../src/mobile/screens/journey/MJourneyEntrySheet';
|
||||
import type { JourneyEntry } from '../../../../src/store/journeyStore';
|
||||
import { fireEvent, render, screen, waitFor } from '../../../helpers/render';
|
||||
import type { GalleryPhoto, JourneyEntry, JourneyPhoto } from '../../../../src/store/journeyStore';
|
||||
import type { ResilientResult, UploadProgress } from '../../../../src/utils/uploadQueue';
|
||||
import { server } from '../../../helpers/msw/server';
|
||||
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render';
|
||||
|
||||
const entry: JourneyEntry = {
|
||||
id: 0,
|
||||
@@ -131,4 +135,815 @@ describe('MJourneyEntrySheet quick capture', () => {
|
||||
);
|
||||
expect(props.onDone).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-001: opens the camera and the gallery picker from the capture buttons', async () => {
|
||||
vi.spyOn(mapsApi, 'reverse').mockResolvedValue({ name: null, address: null });
|
||||
vi.spyOn(weatherApi, 'getCurrent').mockResolvedValue({
|
||||
temp: 0, main: '', description: '', type: '', error: 'unavailable',
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
setup();
|
||||
|
||||
const camera = document.querySelector('input[type="file"][capture="environment"]') as HTMLInputElement;
|
||||
const gallery = document.querySelector('input[type="file"][multiple]') as HTMLInputElement;
|
||||
const cameraClick = vi.spyOn(camera, 'click');
|
||||
const galleryClick = vi.spyOn(gallery, 'click');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Photo' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Gallery' }));
|
||||
|
||||
expect(cameraClick).toHaveBeenCalledTimes(1);
|
||||
expect(galleryClick).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => expect(mapsApi.reverse).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-002: closes a quick capture without a discard prompt', async () => {
|
||||
vi.spyOn(mapsApi, 'reverse').mockResolvedValue({ name: 'Singapore', address: 'Singapore' });
|
||||
vi.spyOn(weatherApi, 'getCurrent').mockResolvedValue({
|
||||
temp: 0, main: '', description: '', type: '', error: 'unavailable',
|
||||
});
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
const user = userEvent.setup();
|
||||
const { props } = setup();
|
||||
|
||||
await waitFor(() => expect(screen.getByDisplayValue('Singapore')).toBeInTheDocument());
|
||||
// Header X and footer button share the "Cancel" label; the footer one is second.
|
||||
await user.click(screen.getAllByRole('button', { name: 'Cancel' })[1]);
|
||||
|
||||
expect(confirmSpy).not.toHaveBeenCalled();
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// FE-MOB-JENTRY-003 to FE-MOB-JENTRY-033 — the full editor, read-only mode and the
|
||||
// quick-capture races the tests above don't reach.
|
||||
|
||||
type ToastKind = 'success' | 'error' | 'warning' | 'info';
|
||||
|
||||
const toastSpy = vi.fn((_message: string, _type?: ToastKind, _duration?: number) => 0);
|
||||
|
||||
function buildEntry(overrides: Partial<JourneyEntry> = {}): JourneyEntry {
|
||||
return {
|
||||
id: 0,
|
||||
journey_id: 7,
|
||||
author_id: 3,
|
||||
type: 'entry',
|
||||
entry_date: '2026-03-15',
|
||||
visibility: 'private',
|
||||
sort_order: 0,
|
||||
photos: [],
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPhoto(id: number): JourneyPhoto {
|
||||
return { id, entry_id: 5, photo_id: id, caption: null, sort_order: 0, shared: 1, created_at: 0 };
|
||||
}
|
||||
|
||||
function buildGalleryPhoto(id: number): GalleryPhoto {
|
||||
return { id, journey_id: 7, photo_id: id, caption: null, shared: 1, sort_order: 0, created_at: 0 };
|
||||
}
|
||||
|
||||
type UploadFn = (
|
||||
entryId: number,
|
||||
files: File[],
|
||||
cbs?: { onProgress?: (p: UploadProgress) => void },
|
||||
) => Promise<ResilientResult<JourneyPhoto>>;
|
||||
|
||||
interface MountOptions {
|
||||
galleryPhotos?: GalleryPhoto[];
|
||||
quickCapture?: boolean;
|
||||
readOnly?: boolean;
|
||||
withDelete?: boolean;
|
||||
upload?: UploadFn;
|
||||
}
|
||||
|
||||
const defaultUpload: UploadFn = async (_entryId, files, cbs) => {
|
||||
cbs?.onProgress?.({ done: files.length, total: files.length, failed: 0, percent: 100 });
|
||||
return { succeeded: [], failed: [] };
|
||||
};
|
||||
|
||||
function mountSheet(sheetEntry: JourneyEntry, opts: MountOptions = {}) {
|
||||
const onClose = vi.fn();
|
||||
const onDone = vi.fn();
|
||||
const onDelete = vi.fn();
|
||||
const onSave = vi.fn(async (_data: Record<string, unknown>) => 42);
|
||||
const onUploadPhotos = vi.fn(opts.upload ?? defaultUpload);
|
||||
const view = render(
|
||||
<MJourneyEntrySheet
|
||||
entry={sheetEntry}
|
||||
galleryPhotos={opts.galleryPhotos ?? []}
|
||||
quickCapture={opts.quickCapture ?? false}
|
||||
readOnly={opts.readOnly ?? false}
|
||||
onClose={onClose}
|
||||
onSave={onSave}
|
||||
onUploadPhotos={onUploadPhotos}
|
||||
onDelete={opts.withDelete ? onDelete : undefined}
|
||||
onDone={onDone}
|
||||
/>
|
||||
);
|
||||
return { ...view, onClose, onDone, onDelete, onSave, onUploadPhotos };
|
||||
}
|
||||
|
||||
const multiFileInput = () => document.querySelector('input[type="file"][multiple]') as HTMLInputElement;
|
||||
const dateField = () => document.querySelector('input[type="date"]') as HTMLInputElement;
|
||||
const timeField = () => document.querySelector('input[type="time"]') as HTMLInputElement;
|
||||
const jpeg = (name = 'a.jpg') => new File(['x'], name, { type: 'image/jpeg' });
|
||||
|
||||
const originalCreateObjectURL = URL.createObjectURL;
|
||||
|
||||
describe('MJourneyEntrySheet full editor', () => {
|
||||
beforeEach(() => {
|
||||
toastSpy.mockClear();
|
||||
window.__addToast = toastSpy;
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
configurable: true, writable: true, value: vi.fn(() => 'blob:preview'),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__addToast;
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
configurable: true, writable: true, value: originalCreateObjectURL,
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-003: shows every editor section for a brand new entry', () => {
|
||||
mountSheet(buildEntry({ entry_date: '' }));
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'New Entry' })).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Give this moment a name...')).toHaveValue('');
|
||||
expect(screen.getByPlaceholderText('Write your story...')).toHaveValue('');
|
||||
expect(screen.getByText('Pros & Cons')).toBeInTheDocument();
|
||||
expect(screen.getByText('Date')).toBeInTheDocument();
|
||||
expect(screen.getByText('Time')).toBeInTheDocument();
|
||||
expect(screen.getByText('Mood')).toBeInTheDocument();
|
||||
expect(screen.getByText('Weather')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tags')).toBeInTheDocument();
|
||||
// An empty entry_date falls back to today, an empty gallery locks the picker.
|
||||
expect(dateField().value).toBe(new Date().toISOString().split('T')[0]);
|
||||
expect(timeField()).toHaveValue('');
|
||||
expect(screen.getByRole('button', { name: 'From Gallery' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Upload photos' })).toBeEnabled();
|
||||
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-004: seeds all fields from an existing entry', () => {
|
||||
mountSheet(buildEntry({
|
||||
id: 5,
|
||||
title: 'Roman holiday',
|
||||
story: 'Gelato everywhere',
|
||||
entry_time: '14:30:00',
|
||||
location_name: 'Rome',
|
||||
location_lat: 41.9,
|
||||
location_lng: 12.5,
|
||||
mood: 'good',
|
||||
weather: 'sunny',
|
||||
tags: ['food', 'city'],
|
||||
pros_cons: { pros: ['Gelato'], cons: ['Queues'] },
|
||||
}));
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'Edit Entry' })).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Roman holiday')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Gelato everywhere')).toBeInTheDocument();
|
||||
expect(timeField()).toHaveValue('14:30');
|
||||
expect(screen.getByDisplayValue('Rome')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Gelato')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Queues')).toBeInTheDocument();
|
||||
expect(screen.getByText('food')).toBeInTheDocument();
|
||||
expect(screen.getByText('city')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Good' }).className).not.toContain('text-m-muted');
|
||||
expect(screen.getByRole('button', { name: 'Sunny' }).className).toContain('bg-m-act');
|
||||
expect(screen.getByRole('button', { name: 'Rainy' }).className).toContain('text-m-muted');
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-005: saves title, story, date, time, pros, cons, tags, mood and weather', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSave, onDone } = mountSheet(buildEntry());
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Give this moment a name...'), 'Rome');
|
||||
await user.type(screen.getByPlaceholderText('Write your story...'), 'Great day');
|
||||
fireEvent.change(dateField(), { target: { value: '2026-03-18' } });
|
||||
fireEvent.change(timeField(), { target: { value: '18:45' } });
|
||||
|
||||
const [addPro, addCon] = screen.getAllByRole('button', { name: /Add another/ });
|
||||
await user.click(addPro);
|
||||
await user.click(addPro);
|
||||
await user.click(addCon);
|
||||
await user.type(screen.getAllByPlaceholderText('Something great...')[0], 'Gelato');
|
||||
await user.type(screen.getByPlaceholderText('Not so great...'), 'Crowds');
|
||||
|
||||
const tagInput = screen.getByPlaceholderText('Add tag');
|
||||
await user.type(tagInput, 'food{Enter}');
|
||||
await user.type(tagInput, 'city,');
|
||||
await user.type(tagInput, 'food{Enter}');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Amazing' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Rainy' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1));
|
||||
// The second (still empty) pro row is dropped and the duplicate tag never lands.
|
||||
expect(onSave).toHaveBeenCalledWith({
|
||||
title: 'Rome',
|
||||
story: 'Great day',
|
||||
entry_date: '2026-03-18',
|
||||
entry_time: '18:45',
|
||||
location_name: null,
|
||||
location_lat: null,
|
||||
location_lng: null,
|
||||
mood: 'amazing',
|
||||
weather: 'rainy',
|
||||
tags: ['food', 'city'],
|
||||
pros_cons: { pros: ['Gelato'], cons: ['Crowds'] },
|
||||
type: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-006: removes a pro and a con row again', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSave } = mountSheet(buildEntry({
|
||||
id: 5,
|
||||
pros_cons: { pros: ['Gelato', 'Sun'], cons: ['Queues', 'Heat'] },
|
||||
}));
|
||||
|
||||
const pro = screen.getByDisplayValue('Sun').parentElement as HTMLElement;
|
||||
await user.click(pro.querySelector('button') as HTMLElement);
|
||||
const con = screen.getByDisplayValue('Heat').parentElement as HTMLElement;
|
||||
await user.click(con.querySelector('button') as HTMLElement);
|
||||
|
||||
expect(screen.queryByDisplayValue('Sun')).not.toBeInTheDocument();
|
||||
expect(screen.queryByDisplayValue('Heat')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave.mock.calls[0][0]).toMatchObject({ pros_cons: { pros: ['Gelato'], cons: ['Queues'] } });
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-007: edits an existing pro and con inline', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSave } = mountSheet(buildEntry({
|
||||
id: 5,
|
||||
pros_cons: { pros: ['Gelato'], cons: ['Queues'] },
|
||||
}));
|
||||
|
||||
await user.type(screen.getByDisplayValue('Gelato'), ' bars');
|
||||
await user.type(screen.getByDisplayValue('Queues'), ' everywhere');
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave.mock.calls[0][0]).toMatchObject({
|
||||
pros_cons: { pros: ['Gelato bars'], cons: ['Queues everywhere'] },
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-008: removes a tag chip', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSave } = mountSheet(buildEntry({ id: 5, tags: ['food', 'city'] }));
|
||||
|
||||
const chip = screen.getByText('city');
|
||||
await user.click(chip.querySelector('button') as HTMLElement);
|
||||
expect(screen.queryByText('city')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave.mock.calls[0][0]).toMatchObject({ tags: ['food'] });
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-009: promotes a skeleton to a real entry once it has a story', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSave } = mountSheet(buildEntry({ id: 9, type: 'skeleton' }));
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Write your story...'), 'Gondolas');
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave.mock.calls[0][0]).toMatchObject({ type: 'entry' });
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-010: leaves an untouched skeleton a skeleton', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSave } = mountSheet(buildEntry({ id: 9, type: 'skeleton', title: 'Venice' }));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave.mock.calls[0][0]).toMatchObject({ type: undefined });
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-011: queues picked files, reports progress and uploads them on save', async () => {
|
||||
const user = userEvent.setup();
|
||||
let release: (r: ResilientResult<JourneyPhoto>) => void = () => {};
|
||||
const upload: UploadFn = (_entryId, files, cbs) =>
|
||||
new Promise<ResilientResult<JourneyPhoto>>(resolve => {
|
||||
cbs?.onProgress?.({ done: 1, total: files.length, failed: 0, percent: 50 });
|
||||
release = resolve;
|
||||
});
|
||||
const { onUploadPhotos, onDone } = mountSheet(buildEntry(), { upload });
|
||||
|
||||
const file = jpeg();
|
||||
await user.click(screen.getByRole('button', { name: 'Upload photos' }));
|
||||
fireEvent.change(multiFileInput(), { target: { files: [file] } });
|
||||
await waitFor(() => expect(document.querySelector('img[src="blob:preview"]')).toBeInTheDocument());
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
expect(await screen.findByText('Uploading 1/1…')).toBeInTheDocument();
|
||||
|
||||
release({ succeeded: [], failed: [] });
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1));
|
||||
expect(onUploadPhotos).toHaveBeenCalledWith(42, [file], expect.anything());
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-012: keeps files that failed to upload and warns about them', async () => {
|
||||
const user = userEvent.setup();
|
||||
const file = jpeg();
|
||||
const { onUploadPhotos } = mountSheet(buildEntry());
|
||||
onUploadPhotos.mockResolvedValueOnce({ succeeded: [], failed: [file] });
|
||||
|
||||
fireEvent.change(multiFileInput(), { target: { files: [file] } });
|
||||
await waitFor(() => expect(document.querySelector('img[src="blob:preview"]')).toBeInTheDocument());
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toastSpy).toHaveBeenCalledWith('1 of 1 photos failed — save again to retry', 'error', undefined)
|
||||
);
|
||||
expect(document.querySelector('img[src="blob:preview"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-013: reports a rejected upload but still finishes the save', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onUploadPhotos, onDone } = mountSheet(buildEntry());
|
||||
onUploadPhotos.mockRejectedValueOnce(new Error('disk full'));
|
||||
|
||||
fireEvent.change(multiFileInput(), { target: { files: [jpeg()] } });
|
||||
await waitFor(() => expect(document.querySelector('img[src="blob:preview"]')).toBeInTheDocument());
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('disk full', 'error', undefined));
|
||||
expect(onDone).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-014: ignores an empty file selection and drops a queued file again', async () => {
|
||||
const user = userEvent.setup();
|
||||
mountSheet(buildEntry());
|
||||
|
||||
fireEvent.change(multiFileInput(), { target: { files: [] } });
|
||||
expect(document.querySelector('img[src="blob:preview"]')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(multiFileInput(), { target: { files: [jpeg()] } });
|
||||
await waitFor(() => expect(document.querySelector('img[src="blob:preview"]')).toBeInTheDocument());
|
||||
|
||||
const preview = document.querySelector('img[src="blob:preview"]') as HTMLElement;
|
||||
await user.click(preview.parentElement!.querySelector('button') as HTMLElement);
|
||||
expect(document.querySelector('img[src="blob:preview"]')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-015: queues gallery photos on a new entry and links them after the save', async () => {
|
||||
const linked: unknown[] = [];
|
||||
server.use(http.post('/api/journeys/entries/42/link-photo', async ({ request }) => {
|
||||
linked.push(await request.json());
|
||||
return HttpResponse.json({ id: 200 });
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
const { onDone } = mountSheet(buildEntry(), { galleryPhotos: [buildGalleryPhoto(200)] });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'From Gallery' }));
|
||||
await user.click(screen.getAllByAltText('')[0]);
|
||||
|
||||
expect(await screen.findByText('All photos already added')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1));
|
||||
expect(linked).toEqual([{ journey_photo_id: 200 }]);
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-016: drops a queued gallery photo again so nothing gets linked', async () => {
|
||||
let calls = 0;
|
||||
server.use(http.post('/api/journeys/entries/42/link-photo', () => {
|
||||
calls += 1;
|
||||
return HttpResponse.json({ id: 200 });
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
const { onDone } = mountSheet(buildEntry(), { galleryPhotos: [buildGalleryPhoto(200)] });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'From Gallery' }));
|
||||
await user.click(screen.getAllByAltText('')[0]);
|
||||
await screen.findByText('All photos already added');
|
||||
|
||||
const tile = document.querySelector('.h-16 img')!.parentElement as HTMLElement;
|
||||
await user.click(tile.querySelector('button[aria-label="Delete"]') as HTMLElement);
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1));
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-017: links a gallery photo straight away on a saved entry', async () => {
|
||||
let linked = false;
|
||||
server.use(http.post('/api/journeys/entries/5/link-photo', () => {
|
||||
linked = true;
|
||||
return HttpResponse.json(buildPhoto(200));
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
mountSheet(buildEntry({ id: 5 }), { galleryPhotos: [buildGalleryPhoto(200)] });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'From Gallery' }));
|
||||
await user.click(screen.getAllByAltText('')[0]);
|
||||
|
||||
await waitFor(() => expect(linked).toBe(true));
|
||||
expect(await screen.findByText('All photos already added')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-018: keeps the picker open when linking a gallery photo fails', async () => {
|
||||
let attempts = 0;
|
||||
server.use(http.post('/api/journeys/entries/5/link-photo', () => {
|
||||
attempts += 1;
|
||||
return new HttpResponse(null, { status: 500 });
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
mountSheet(buildEntry({ id: 5 }), { galleryPhotos: [buildGalleryPhoto(200)] });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'From Gallery' }));
|
||||
await user.click(screen.getAllByAltText('')[0]);
|
||||
|
||||
await waitFor(() => expect(attempts).toBe(1));
|
||||
expect(screen.queryByText('All photos already added')).not.toBeInTheDocument();
|
||||
expect(screen.getAllByAltText('')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-019: unlinks a photo removed from a saved entry', async () => {
|
||||
let unlinked = false;
|
||||
server.use(http.delete('/api/journeys/entries/5/photos/100', () => {
|
||||
unlinked = true;
|
||||
return HttpResponse.json({ ok: true });
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
mountSheet(buildEntry({ id: 5, photos: [buildPhoto(100)] }));
|
||||
|
||||
const tile = document.querySelector('img[src="/api/photos/100/thumbnail"]')!.parentElement as HTMLElement;
|
||||
await user.click(tile.querySelector('button[aria-label="Delete"]') as HTMLElement);
|
||||
|
||||
await waitFor(() => expect(unlinked).toBe(true));
|
||||
expect(document.querySelector('img[src="/api/photos/100/thumbnail"]')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-020: promoting a photo to first persists the new sort order', async () => {
|
||||
const patched: Array<{ id: string; body: unknown }> = [];
|
||||
server.use(http.patch('/api/journeys/photos/:id', async ({ params, request }) => {
|
||||
patched.push({ id: String(params.id), body: await request.json() });
|
||||
return HttpResponse.json({ ok: true });
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
mountSheet(buildEntry({ id: 5, photos: [buildPhoto(100), buildPhoto(101)] }));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '1st' }));
|
||||
|
||||
await waitFor(() => expect(patched).toHaveLength(2));
|
||||
expect(patched).toEqual([
|
||||
{ id: '101', body: { sort_order: 0 } },
|
||||
{ id: '100', body: { sort_order: 1 } },
|
||||
]);
|
||||
const order = Array.from(document.querySelectorAll('.h-16 img')).map(i => i.getAttribute('src'));
|
||||
expect(order[0]).toBe('/api/photos/101/thumbnail');
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-021: toggles mood and weather chips back off', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSave } = mountSheet(buildEntry({ id: 5, mood: 'rough', weather: 'cold' }));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Rough' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Snowy' }));
|
||||
expect(screen.getByRole('button', { name: 'Rough' }).className).toContain('text-m-muted');
|
||||
expect(screen.getByRole('button', { name: 'Snowy' }).className).not.toContain('bg-m-act');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave.mock.calls[0][0]).toMatchObject({ mood: null, weather: null });
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-022: picks a searched location and stores its coordinates', async () => {
|
||||
server.use(http.post('/api/maps/search', () => HttpResponse.json({
|
||||
places: [
|
||||
{ name: 'Roma Termini', address: 'Piazza dei Cinquecento', lat: '41.9', lng: '12.5' },
|
||||
{ name: 'Roma Ostiense', lat: 41.86, lng: 12.48 },
|
||||
],
|
||||
})));
|
||||
const user = userEvent.setup();
|
||||
const { onSave } = mountSheet(buildEntry({ id: 5 }));
|
||||
const input = screen.getByPlaceholderText('Search location...');
|
||||
|
||||
// The second keystroke has to clear the timer the first one armed.
|
||||
fireEvent.change(input, { target: { value: 'Ro' } });
|
||||
fireEvent.change(input, { target: { value: 'Rom' } });
|
||||
|
||||
const hit = await screen.findByText('Roma Termini', {}, { timeout: 3000 });
|
||||
expect(screen.getByText('Piazza dei Cinquecento')).toBeInTheDocument();
|
||||
expect(screen.getByText('Roma Ostiense')).toBeInTheDocument();
|
||||
await user.click(hit);
|
||||
|
||||
expect(screen.getByDisplayValue('Roma Termini')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Piazza dei Cinquecento')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave.mock.calls[0][0]).toMatchObject({
|
||||
location_name: 'Roma Termini', location_lat: 41.9, location_lng: 12.5,
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-023: clears the suggestions for a query shorter than two characters', async () => {
|
||||
server.use(http.post('/api/maps/search', () => HttpResponse.json({
|
||||
places: [{ name: 'Roma Termini', address: 'Piazza dei Cinquecento', lat: 41.9, lng: 12.5 }],
|
||||
})));
|
||||
mountSheet(buildEntry({ id: 5 }));
|
||||
const input = screen.getByPlaceholderText('Search location...');
|
||||
|
||||
// Focus without results must not open the dropdown.
|
||||
fireEvent.focus(input);
|
||||
expect(screen.queryByText('Roma Termini')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(input, { target: { value: 'Roma' } });
|
||||
await screen.findByText('Roma Termini', {}, { timeout: 3000 });
|
||||
|
||||
// Re-focusing with results in hand keeps the dropdown open.
|
||||
fireEvent.focus(input);
|
||||
expect(screen.getByText('Roma Termini')).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(input, { target: { value: 'R' } });
|
||||
expect(screen.queryByText('Roma Termini')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-035: shows no suggestions when the search payload carries no places', async () => {
|
||||
let calls = 0;
|
||||
server.use(http.post('/api/maps/search', () => {
|
||||
calls += 1;
|
||||
return HttpResponse.json({});
|
||||
}));
|
||||
mountSheet(buildEntry({ id: 5 }));
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search location...'), { target: { value: 'Roma' } });
|
||||
|
||||
await waitFor(() => expect(calls).toBe(1), { timeout: 3000 });
|
||||
expect(document.querySelector('.absolute.left-0.right-0.top-full')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-036: leaves the strip untouched when the link call returns nothing', async () => {
|
||||
server.use(http.post('/api/journeys/entries/5/link-photo', () => HttpResponse.json(null)));
|
||||
const user = userEvent.setup();
|
||||
mountSheet(
|
||||
buildEntry({ id: 5, photos: undefined as unknown as JourneyPhoto[] }),
|
||||
{ galleryPhotos: [buildGalleryPhoto(200)] },
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'From Gallery' }));
|
||||
await user.click(screen.getAllByAltText('')[0]);
|
||||
|
||||
await waitFor(() => expect(screen.getAllByAltText('')).toHaveLength(1));
|
||||
expect(document.querySelector('.h-16 img')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('All photos already added')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-037: reorders photos locally even when persisting the order fails', async () => {
|
||||
let attempts = 0;
|
||||
server.use(http.patch('/api/journeys/photos/:id', () => {
|
||||
attempts += 1;
|
||||
return new HttpResponse(null, { status: 500 });
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
mountSheet(buildEntry({ id: 5, photos: [buildPhoto(100), buildPhoto(101)] }));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '1st' }));
|
||||
|
||||
await waitFor(() => expect(attempts).toBe(2));
|
||||
const order = Array.from(document.querySelectorAll('.h-16 img')).map(i => i.getAttribute('src'));
|
||||
expect(order).toEqual(['/api/photos/101/thumbnail', '/api/photos/100/thumbnail']);
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-024: shows no suggestions when the location search fails', async () => {
|
||||
let calls = 0;
|
||||
server.use(http.post('/api/maps/search', () => {
|
||||
calls += 1;
|
||||
return new HttpResponse(null, { status: 500 });
|
||||
}));
|
||||
mountSheet(buildEntry({ id: 5 }));
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search location...'), { target: { value: 'Roma' } });
|
||||
|
||||
await waitFor(() => expect(calls).toBe(1), { timeout: 3000 });
|
||||
expect(screen.getByDisplayValue('Roma')).toBeInTheDocument();
|
||||
expect(document.querySelector('.absolute.left-0.right-0.top-full')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-025: asks before discarding a dirty editor', async () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
const user = userEvent.setup();
|
||||
const { onClose } = mountSheet(buildEntry({ id: 5, title: 'Rome' }));
|
||||
|
||||
await user.type(screen.getByDisplayValue('Rome'), '!');
|
||||
await user.click(screen.getAllByRole('button', { name: 'Cancel' })[1]);
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalledWith('You have unsaved changes. Discard them?');
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
confirmSpy.mockReturnValue(true);
|
||||
await user.click(screen.getAllByRole('button', { name: 'Cancel' })[1]);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-026: closes an untouched editor from the header without asking', async () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
const user = userEvent.setup();
|
||||
const { onClose } = mountSheet(buildEntry({ id: 5, title: 'Rome' }));
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: 'Cancel' })[0]);
|
||||
|
||||
expect(confirmSpy).not.toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-027: offers deleting the entry', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onDelete } = mountSheet(buildEntry({ id: 5 }), { withDelete: true });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
|
||||
expect(onDelete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MJourneyEntrySheet read-only', () => {
|
||||
it('FE-MOB-JENTRY-028: renders a contributor entry without any editing affordance', () => {
|
||||
mountSheet(buildEntry({
|
||||
id: 5,
|
||||
title: 'Roman holiday',
|
||||
story: 'Gelato everywhere',
|
||||
location_name: 'Rome',
|
||||
location_lat: 41.9,
|
||||
location_lng: 12.5,
|
||||
mood: 'good',
|
||||
weather: 'sunny',
|
||||
tags: ['food'],
|
||||
pros_cons: { pros: ['Gelato'], cons: ['Queues'] },
|
||||
photos: [buildPhoto(100), buildPhoto(101)],
|
||||
}), { readOnly: true, withDelete: true, galleryPhotos: [buildGalleryPhoto(200)] });
|
||||
|
||||
expect(screen.getByText('Roman holiday')).toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText('Give this moment a name...')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Gelato everywhere')).toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText('Write your story...')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Upload photos' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'From Gallery' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '1st' })).not.toBeInTheDocument();
|
||||
expect(screen.queryAllByRole('button', { name: /Add another/ })).toHaveLength(0);
|
||||
expect(screen.getByDisplayValue('Gelato')).toHaveAttribute('readonly');
|
||||
expect(screen.getByDisplayValue('Queues')).toHaveAttribute('readonly');
|
||||
expect(screen.getByDisplayValue('Rome')).toHaveAttribute('readonly');
|
||||
expect(dateField()).toBeDisabled();
|
||||
expect(timeField()).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Good' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Sunny' })).toBeDisabled();
|
||||
expect(screen.getByText('food')).toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText('Add tag')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-029: hides story, pros/cons and tags when a read-only entry has none', () => {
|
||||
mountSheet(buildEntry({ id: 5 }), { readOnly: true });
|
||||
|
||||
expect(screen.getByText('Give this moment a name...')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Pros & Cons')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Tags')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Mood')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-030: closes a read-only sheet without a discard prompt', async () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
const user = userEvent.setup();
|
||||
const { onClose } = mountSheet(buildEntry({ id: 5, title: 'Rome' }), { readOnly: true });
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: 'Cancel' })[1]);
|
||||
|
||||
expect(confirmSpy).not.toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MJourneyEntrySheet quick capture edge cases', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-031: falls back to a generic error when the device has no geolocation', () => {
|
||||
Object.defineProperty(navigator, 'geolocation', { configurable: true, value: undefined });
|
||||
mountSheet(buildEntry({ entry_time: '09:07' }), { quickCapture: true });
|
||||
|
||||
expect(screen.getByText('Error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-032: skips the location lookup when the entry already has coordinates', () => {
|
||||
const getCurrentPosition = vi.fn();
|
||||
Object.defineProperty(navigator, 'geolocation', { configurable: true, value: { getCurrentPosition } });
|
||||
mountSheet(
|
||||
buildEntry({ location_lat: 41.9, location_lng: 12.5, location_name: 'Rome' }),
|
||||
{ quickCapture: true },
|
||||
);
|
||||
|
||||
expect(getCurrentPosition).not.toHaveBeenCalled();
|
||||
expect(screen.getByDisplayValue('Rome')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-038: still applies the weather when reverse geocoding fails', async () => {
|
||||
Object.defineProperty(navigator, 'geolocation', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getCurrentPosition: vi.fn((s: PositionCallback) =>
|
||||
s({ coords: { latitude: 1.35, longitude: 103.8 } } as GeolocationPosition)),
|
||||
},
|
||||
});
|
||||
vi.spyOn(mapsApi, 'reverse').mockRejectedValue(new Error('nominatim down'));
|
||||
vi.spyOn(weatherApi, 'getCurrent').mockResolvedValue({
|
||||
temp: 31, main: 'Clear', description: 'clear sky', type: 'current',
|
||||
});
|
||||
mountSheet(buildEntry(), { quickCapture: true });
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Sunny' }).className).toContain('bg-m-act'));
|
||||
expect(screen.getByPlaceholderText('Search location...')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-033: ignores a position that only arrives after the sheet is gone', async () => {
|
||||
let success: PositionCallback | undefined;
|
||||
Object.defineProperty(navigator, 'geolocation', {
|
||||
configurable: true,
|
||||
value: { getCurrentPosition: vi.fn((s: PositionCallback) => { success = s }) },
|
||||
});
|
||||
const reverse = vi.spyOn(mapsApi, 'reverse');
|
||||
const { unmount } = mountSheet(buildEntry(), { quickCapture: true });
|
||||
|
||||
unmount();
|
||||
await act(async () => {
|
||||
success?.({ coords: { latitude: 1, longitude: 2 } } as GeolocationPosition);
|
||||
});
|
||||
|
||||
expect(reverse).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-034: keeps only the newest lookup when the effect restarts mid-flight', async () => {
|
||||
const resolvers: Array<(v: { name: string; address: string }) => void> = [];
|
||||
const failures: PositionErrorCallback[] = [];
|
||||
Object.defineProperty(navigator, 'geolocation', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getCurrentPosition: vi.fn((s: PositionCallback, e: PositionErrorCallback) => {
|
||||
failures.push(e);
|
||||
s({ coords: { latitude: 1.35, longitude: 103.8 } } as GeolocationPosition);
|
||||
}),
|
||||
},
|
||||
});
|
||||
vi.spyOn(mapsApi, 'reverse').mockImplementation(
|
||||
() => new Promise(resolve => {
|
||||
resolvers.push(resolve as (v: { name: string; address: string }) => void);
|
||||
})
|
||||
);
|
||||
vi.spyOn(weatherApi, 'getCurrent').mockResolvedValue({
|
||||
temp: 0, main: '', description: '', type: 'current', error: 'unavailable',
|
||||
});
|
||||
|
||||
const base = buildEntry();
|
||||
const props = {
|
||||
galleryPhotos: [],
|
||||
quickCapture: true,
|
||||
onClose: vi.fn(),
|
||||
onSave: vi.fn(async () => 42),
|
||||
onUploadPhotos: vi.fn(defaultUpload),
|
||||
onDone: vi.fn(),
|
||||
};
|
||||
const { rerender } = render(<MJourneyEntrySheet entry={base} {...props} />);
|
||||
await waitFor(() => expect(resolvers).toHaveLength(1));
|
||||
|
||||
rerender(<MJourneyEntrySheet entry={{ ...base, entry_date: '2026-03-16' }} {...props} />);
|
||||
await waitFor(() => expect(resolvers).toHaveLength(2));
|
||||
|
||||
await act(async () => {
|
||||
resolvers[0]({ name: 'Stale', address: 'Stale' });
|
||||
resolvers[1]({ name: 'Fresh', address: 'Fresh' });
|
||||
});
|
||||
|
||||
expect(await screen.findByDisplayValue('Fresh')).toBeInTheDocument();
|
||||
expect(screen.queryByDisplayValue('Stale')).not.toBeInTheDocument();
|
||||
|
||||
// The abandoned watcher must not be able to paint an error either, and a
|
||||
// failure without a message falls back to the generic label.
|
||||
await act(async () => {
|
||||
failures[0]({ message: 'stale failure' } as GeolocationPositionError);
|
||||
failures[1]({ message: '' } as GeolocationPositionError);
|
||||
});
|
||||
expect(screen.queryByText('stale failure')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
// FE-MOB-JSET-001 to FE-MOB-JSET-030
|
||||
import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor, within } from '../../../helpers/render';
|
||||
import MJourneySettingsSheet from '../../../../src/mobile/screens/journey/MJourneySettingsSheet';
|
||||
import { journeyApi } from '../../../../src/api/client';
|
||||
import { useJourneyStore } from '../../../../src/store/journeyStore';
|
||||
import type { JourneyDetail } from '../../../../src/store/journeyStore';
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
|
||||
return { ...actual, useNavigate: () => mockNavigate };
|
||||
});
|
||||
|
||||
type AddToast = NonNullable<typeof window.__addToast>;
|
||||
let addToast: Mock<AddToast>;
|
||||
|
||||
const journeyStoreInitial = useJourneyStore.getState();
|
||||
|
||||
function buildJourney(over: Partial<JourneyDetail> = {}): JourneyDetail {
|
||||
return {
|
||||
id: 12,
|
||||
user_id: 1,
|
||||
title: 'Japan 2026',
|
||||
subtitle: 'Tokyo & Kyoto',
|
||||
cover_gradient: null,
|
||||
cover_image: null,
|
||||
status: 'active',
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
entries: [],
|
||||
gallery: [],
|
||||
trips: [
|
||||
{ trip_id: 3, added_at: 0, title: 'Tokyo trip', start_date: '2026-05-01', end_date: '2026-05-09', cover_image: null, currency: 'EUR', place_count: 8 },
|
||||
],
|
||||
contributors: [
|
||||
{ journey_id: 12, user_id: 1, role: 'owner', added_at: 0, username: 'maurice', avatar: null },
|
||||
{ journey_id: 12, user_id: 2, role: 'editor', added_at: 0, username: 'julien', avatar: null },
|
||||
],
|
||||
stats: { entries: 2, photos: 5, places: 8 },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function setup(over: Partial<JourneyDetail> = {}) {
|
||||
const props = {
|
||||
journey: buildJourney(over),
|
||||
onClose: vi.fn(),
|
||||
onSaved: vi.fn(),
|
||||
onOpenInvite: vi.fn(),
|
||||
onRefresh: vi.fn(),
|
||||
};
|
||||
const view = render(<MJourneySettingsSheet {...props} />);
|
||||
return { ...view, props };
|
||||
}
|
||||
|
||||
let updateJourney: Mock<(id: number, data: Record<string, unknown>) => Promise<void>>;
|
||||
let deleteJourney: Mock<(id: number) => Promise<void>>;
|
||||
|
||||
/** The sheet and the confirm dialog both portal into body — scope by dialog title. */
|
||||
async function confirmButton(dialogTitle: string, label: string): Promise<HTMLElement> {
|
||||
const heading = await screen.findByRole('heading', { name: dialogTitle });
|
||||
const modal = heading.closest('.trek-modal-enter') as HTMLElement;
|
||||
return within(modal).getByRole('button', { name: label });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
addToast = vi.fn<AddToast>(() => 0);
|
||||
window.__addToast = addToast;
|
||||
updateJourney = vi.fn(async () => {});
|
||||
deleteJourney = vi.fn(async () => {});
|
||||
useJourneyStore.setState({ ...journeyStoreInitial, updateJourney, deleteJourney }, true);
|
||||
vi.spyOn(journeyApi, 'getShareLink').mockResolvedValue({ link: null });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__addToast;
|
||||
vi.restoreAllMocks();
|
||||
useJourneyStore.setState(journeyStoreInitial, true);
|
||||
});
|
||||
|
||||
describe('MJourneySettingsSheet', () => {
|
||||
it('FE-MOB-JSET-001: renders the journey name, subtitle and synced trips', async () => {
|
||||
setup();
|
||||
expect(await screen.findByDisplayValue('Japan 2026')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Tokyo & Kyoto')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tokyo trip')).toBeInTheDocument();
|
||||
expect(screen.getByText('8 places')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JSET-002: shows the empty hint when no trip is linked', async () => {
|
||||
setup({ trips: [] });
|
||||
expect(await screen.findByText('No trips linked')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JSET-003: saving posts the edited title and subtitle and reports back', async () => {
|
||||
const { props } = setup();
|
||||
fireEvent.change(await screen.findByDisplayValue('Japan 2026'), { target: { value: 'Japan 2027' } });
|
||||
fireEvent.change(screen.getByDisplayValue('Tokyo & Kyoto'), { target: { value: '' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => expect(updateJourney).toHaveBeenCalledWith(12, { title: 'Japan 2027', subtitle: null }));
|
||||
expect(props.onSaved).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-JSET-004: a failing save shows an error toast and keeps the sheet open', async () => {
|
||||
updateJourney.mockRejectedValueOnce(new Error('nope'));
|
||||
const { props } = setup();
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to save', 'error', undefined));
|
||||
expect(props.onSaved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-JSET-005: the save button is disabled while the title is empty', async () => {
|
||||
setup();
|
||||
fireEvent.change(await screen.findByDisplayValue('Japan 2026'), { target: { value: ' ' } });
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-MOB-JSET-006: uploading a cover sends the file and refreshes', async () => {
|
||||
const upload = vi.spyOn(journeyApi, 'uploadCover').mockResolvedValue({});
|
||||
const { props, container } = setup();
|
||||
const input = container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const file = new File(['x'], 'cover.jpg', { type: 'image/jpeg' });
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
|
||||
await waitFor(() => expect(upload).toHaveBeenCalledTimes(1));
|
||||
expect(upload.mock.calls[0][0]).toBe(12);
|
||||
expect((upload.mock.calls[0][1] as FormData).get('cover')).toBe(file);
|
||||
await waitFor(() => expect(props.onRefresh).toHaveBeenCalled());
|
||||
expect(addToast).toHaveBeenCalledWith('Cover updated', 'success', undefined);
|
||||
});
|
||||
|
||||
it('FE-MOB-JSET-007: a failing cover upload shows an error toast', async () => {
|
||||
vi.spyOn(journeyApi, 'uploadCover').mockRejectedValue(new Error('too big'));
|
||||
const { props, container } = setup();
|
||||
const input = container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { files: [new File(['x'], 'cover.jpg', { type: 'image/jpeg' })] } });
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Upload failed', 'error', undefined));
|
||||
expect(props.onRefresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-JSET-008: choosing no file leaves the upload untouched', async () => {
|
||||
const upload = vi.spyOn(journeyApi, 'uploadCover').mockResolvedValue({});
|
||||
const { container } = setup();
|
||||
const input = container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { files: [] } });
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-JSET-009: Add trip loads the available trips and hides already linked ones', async () => {
|
||||
vi.spyOn(journeyApi, 'availableTrips').mockResolvedValue({
|
||||
trips: [{ id: 3, title: 'Tokyo trip' }, { id: 4, title: 'Oslo trip' }],
|
||||
});
|
||||
setup();
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Add Trip' }));
|
||||
|
||||
expect(await screen.findByText('Oslo trip')).toBeInTheDocument();
|
||||
// trip 3 is already linked, so it only appears once — in the synced list
|
||||
expect(screen.getAllByText('Tokyo trip')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-JSET-010: linking a trip calls the API and refreshes', async () => {
|
||||
vi.spyOn(journeyApi, 'availableTrips').mockResolvedValue({ trips: [{ id: 4, title: 'Oslo trip' }] });
|
||||
const addTrip = vi.spyOn(journeyApi, 'addTrip').mockResolvedValue({});
|
||||
const { props } = setup();
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Add Trip' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Link' }));
|
||||
|
||||
await waitFor(() => expect(addTrip).toHaveBeenCalledWith(12, 4));
|
||||
expect(addToast).toHaveBeenCalledWith('Trip linked', 'success', undefined);
|
||||
expect(props.onRefresh).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-JSET-011: a failing link shows an error toast', async () => {
|
||||
vi.spyOn(journeyApi, 'availableTrips').mockResolvedValue({ trips: [{ id: 4, title: 'Oslo trip' }] });
|
||||
vi.spyOn(journeyApi, 'addTrip').mockRejectedValue(new Error('boom'));
|
||||
setup();
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Add Trip' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Link' }));
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to link trip', 'error', undefined));
|
||||
});
|
||||
|
||||
it('FE-MOB-JSET-012: an empty available-trips response shows the placeholder', async () => {
|
||||
vi.spyOn(journeyApi, 'availableTrips').mockRejectedValue(new Error('offline'));
|
||||
setup();
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Add Trip' }));
|
||||
expect(await screen.findByText('No trips available')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JSET-013: unlinking a trip asks for confirmation and then removes it', async () => {
|
||||
const removeTrip = vi.spyOn(journeyApi, 'removeTrip').mockResolvedValue({});
|
||||
const { props } = setup();
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Unlink Trip' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Unlink' }));
|
||||
|
||||
await waitFor(() => expect(removeTrip).toHaveBeenCalledWith(12, 3));
|
||||
expect(addToast).toHaveBeenCalledWith('Trip unlinked', 'success', undefined);
|
||||
expect(props.onRefresh).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-JSET-014: a failing unlink shows an error toast', async () => {
|
||||
vi.spyOn(journeyApi, 'removeTrip').mockRejectedValue(new Error('boom'));
|
||||
setup();
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Unlink Trip' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Unlink' }));
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to unlink trip', 'error', undefined));
|
||||
});
|
||||
|
||||
it('FE-MOB-JSET-015: contributors are listed and only non-owners can be removed', async () => {
|
||||
const remove = vi.spyOn(journeyApi, 'removeContributor').mockResolvedValue({});
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
const { props } = setup();
|
||||
|
||||
expect(await screen.findByText('maurice')).toBeInTheDocument();
|
||||
expect(screen.getByText('julien')).toBeInTheDocument();
|
||||
const removeButtons = screen.getAllByRole('button', { name: 'Remove contributor' });
|
||||
expect(removeButtons).toHaveLength(1);
|
||||
|
||||
fireEvent.click(removeButtons[0]);
|
||||
await waitFor(() => expect(remove).toHaveBeenCalledWith(12, 2));
|
||||
expect(addToast).toHaveBeenCalledWith('Contributor removed', 'success', undefined);
|
||||
expect(props.onRefresh).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-JSET-016: declining the confirm keeps the contributor', async () => {
|
||||
const remove = vi.spyOn(journeyApi, 'removeContributor').mockResolvedValue({});
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
setup();
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Remove contributor' }));
|
||||
expect(remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-JSET-017: a failing contributor removal shows an error toast', async () => {
|
||||
vi.spyOn(journeyApi, 'removeContributor').mockRejectedValue(new Error('nope'));
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
setup();
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Remove contributor' }));
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to remove contributor', 'error', undefined));
|
||||
});
|
||||
|
||||
it('FE-MOB-JSET-018: creating a share link reveals the public URL and its toggles', async () => {
|
||||
vi.spyOn(journeyApi, 'createShareLink').mockResolvedValue({ token: 'tok-1' });
|
||||
setup();
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Create share link' }));
|
||||
|
||||
expect(await screen.findByText(`${window.location.origin}/public/journey/tok-1`)).toBeInTheDocument();
|
||||
expect(addToast).toHaveBeenCalledWith('Share link created', 'success', undefined);
|
||||
expect(screen.getAllByRole('switch')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('FE-MOB-JSET-019: a failing share-link creation shows an error toast', async () => {
|
||||
vi.spyOn(journeyApi, 'createShareLink').mockRejectedValue(new Error('boom'));
|
||||
setup();
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Create share link' }));
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to create link', 'error', undefined));
|
||||
});
|
||||
|
||||
it('FE-MOB-JSET-020: an existing link is loaded, copied and its permissions toggled', async () => {
|
||||
vi.mocked(journeyApi.getShareLink).mockResolvedValue({
|
||||
link: { token: 'tok-9', share_timeline: true, share_gallery: true, share_map: false },
|
||||
});
|
||||
const create = vi.spyOn(journeyApi, 'createShareLink').mockResolvedValue({ token: 'tok-9' });
|
||||
const writeText = vi.fn(async () => {});
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } });
|
||||
setup();
|
||||
|
||||
expect(await screen.findByText(`${window.location.origin}/public/journey/tok-9`)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy' }));
|
||||
expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/public/journey/tok-9`);
|
||||
expect(await screen.findByRole('button', { name: 'Copied!' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Map' }));
|
||||
await waitFor(() =>
|
||||
expect(create).toHaveBeenCalledWith(12, { share_timeline: true, share_gallery: true, share_map: true }),
|
||||
);
|
||||
expect(screen.getByRole('switch', { name: 'Map' })).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
it('FE-MOB-JSET-021: a failing permission toggle rolls the switch back', async () => {
|
||||
vi.mocked(journeyApi.getShareLink).mockResolvedValue({
|
||||
link: { token: 'tok-9', share_timeline: true, share_gallery: true, share_map: true },
|
||||
});
|
||||
vi.spyOn(journeyApi, 'createShareLink').mockRejectedValue(new Error('boom'));
|
||||
setup();
|
||||
|
||||
fireEvent.click(await screen.findByRole('switch', { name: 'Gallery' }));
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to update', 'error', undefined));
|
||||
expect(screen.getByRole('switch', { name: 'Gallery' })).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
it('FE-MOB-JSET-022: removing the share link hides the URL again', async () => {
|
||||
vi.mocked(journeyApi.getShareLink).mockResolvedValue({
|
||||
link: { token: 'tok-9', share_timeline: true, share_gallery: true, share_map: true },
|
||||
});
|
||||
const del = vi.spyOn(journeyApi, 'deleteShareLink').mockResolvedValue({});
|
||||
setup();
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Remove share link' }));
|
||||
await waitFor(() => expect(del).toHaveBeenCalledWith(12));
|
||||
expect(await screen.findByRole('button', { name: 'Create share link' })).toBeInTheDocument();
|
||||
expect(addToast).toHaveBeenCalledWith('Share link deleted', 'success', undefined);
|
||||
});
|
||||
|
||||
it('FE-MOB-JSET-023: a failing share-link removal shows an error toast', async () => {
|
||||
vi.mocked(journeyApi.getShareLink).mockResolvedValue({
|
||||
link: { token: 'tok-9', share_timeline: true, share_gallery: true, share_map: true },
|
||||
});
|
||||
vi.spyOn(journeyApi, 'deleteShareLink').mockRejectedValue(new Error('boom'));
|
||||
setup();
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Remove share link' }));
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to delete', 'error', undefined));
|
||||
});
|
||||
|
||||
it('FE-MOB-JSET-024: archiving an active journey flips its status', async () => {
|
||||
const { props } = setup();
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Archive Journey' }));
|
||||
|
||||
await waitFor(() => expect(updateJourney).toHaveBeenCalledWith(12, { status: 'archived' }));
|
||||
expect(addToast).toHaveBeenCalledWith('Journey archived', 'success', undefined);
|
||||
expect(props.onSaved).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-JSET-025: an archived journey offers to restore it', async () => {
|
||||
setup({ status: 'archived' });
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Restore Journey' }));
|
||||
|
||||
await waitFor(() => expect(updateJourney).toHaveBeenCalledWith(12, { status: 'active' }));
|
||||
expect(addToast).toHaveBeenCalledWith('Journey reopened', 'success', undefined);
|
||||
});
|
||||
|
||||
it('FE-MOB-JSET-026: a failing archive shows an error toast', async () => {
|
||||
updateJourney.mockRejectedValueOnce(new Error('nope'));
|
||||
setup();
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Archive Journey' }));
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to save', 'error', undefined));
|
||||
});
|
||||
|
||||
it('FE-MOB-JSET-027: deleting the journey confirms and returns to the list', async () => {
|
||||
setup();
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Delete' }));
|
||||
fireEvent.click(await confirmButton('Delete Journey', 'Delete'));
|
||||
|
||||
await waitFor(() => expect(deleteJourney).toHaveBeenCalledWith(12));
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/journey');
|
||||
});
|
||||
|
||||
it('FE-MOB-JSET-028: a failing delete keeps the user on the sheet', async () => {
|
||||
deleteJourney.mockRejectedValueOnce(new Error('nope'));
|
||||
setup();
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Delete' }));
|
||||
fireEvent.click(await confirmButton('Delete Journey', 'Delete'));
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to delete', 'error', undefined));
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-JSET-029: the cancel and invite buttons call back out', async () => {
|
||||
const { props } = setup();
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Invite Contributor' }));
|
||||
expect(props.onOpenInvite).toHaveBeenCalledTimes(1);
|
||||
|
||||
const cancels = screen.getAllByRole('button', { name: 'Cancel' });
|
||||
fireEvent.click(cancels[cancels.length - 1]);
|
||||
expect(props.onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-JSET-030: an existing cover is shown instead of the gradient', async () => {
|
||||
setup({ cover_image: 'covers/j.jpg' });
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector('img[src="/uploads/covers/j.jpg"]')).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Change cover' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '../../../helpers/render';
|
||||
import { resetAllStores } from '../../../helpers/store';
|
||||
import MNotificationRow from '../../../../src/mobile/screens/notifications/MNotificationRow';
|
||||
import {
|
||||
useInAppNotificationStore, type InAppNotification,
|
||||
} from '../../../../src/store/inAppNotificationStore';
|
||||
|
||||
// FE-MOB-NROW-001 onwards
|
||||
|
||||
const markRead = vi.fn(async (_id: number) => {});
|
||||
const deleteNotification = vi.fn(async (_id: number) => {});
|
||||
const respondToBoolean = vi.fn(async (_id: number, _r: 'positive' | 'negative') => {});
|
||||
|
||||
function buildNotification(over: Partial<InAppNotification> = {}): InAppNotification {
|
||||
return {
|
||||
id: 3,
|
||||
type: 'simple',
|
||||
scope: 'trip',
|
||||
target: 1,
|
||||
sender_id: 2,
|
||||
sender_username: 'maurice',
|
||||
sender_avatar: null,
|
||||
recipient_id: 1,
|
||||
title_key: 'notif.title',
|
||||
title_params: {},
|
||||
text_key: 'notif.text',
|
||||
text_params: {},
|
||||
positive_text_key: null,
|
||||
negative_text_key: null,
|
||||
response: null,
|
||||
navigate_text_key: null,
|
||||
navigate_target: null,
|
||||
is_read: false,
|
||||
created_at: new Date().toISOString(),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const agesAgo = (minutes: number) => new Date(Date.now() - minutes * 60000).toISOString();
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
markRead.mockClear();
|
||||
deleteNotification.mockClear();
|
||||
respondToBoolean.mockClear();
|
||||
useInAppNotificationStore.setState({ markRead, deleteNotification, respondToBoolean });
|
||||
});
|
||||
|
||||
describe('MNotificationRow', () => {
|
||||
it('FE-MOB-NROW-001: shows title, body, sender initial and the unread dot', () => {
|
||||
const { container } = render(<MNotificationRow notification={buildNotification()} />);
|
||||
|
||||
expect(screen.getByText('notif.title')).toBeInTheDocument();
|
||||
expect(screen.getByText('notif.text')).toBeInTheDocument();
|
||||
expect(screen.getByText('M')).toBeInTheDocument();
|
||||
expect(container.querySelectorAll('span[aria-hidden]')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-NROW-002: a read notification drops the dot', () => {
|
||||
const { container } = render(<MNotificationRow notification={buildNotification({ is_read: true })} />);
|
||||
|
||||
expect(container.querySelectorAll('span[aria-hidden]')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('FE-MOB-NROW-003: renders the sender avatar when there is one', () => {
|
||||
const { container } = render(
|
||||
<MNotificationRow notification={buildNotification({ sender_avatar: '/uploads/avatars/m.jpg' })} />,
|
||||
);
|
||||
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', '/uploads/avatars/m.jpg');
|
||||
expect(screen.queryByText('M')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-NROW-004: a system notification falls back to the bell tile', () => {
|
||||
const { container } = render(
|
||||
<MNotificationRow notification={buildNotification({ sender_username: null })} />,
|
||||
);
|
||||
|
||||
expect(container.querySelector('.lucide-bell')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[0, 'just now'],
|
||||
[7, '7m'],
|
||||
[180, '3h'],
|
||||
[60 * 24 * 2, '2d'],
|
||||
])('FE-MOB-NROW-005: %s minutes old renders as %s', (minutes, label) => {
|
||||
render(<MNotificationRow notification={buildNotification({ created_at: agesAgo(minutes) })} />);
|
||||
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-NROW-006: tapping an unread row marks it read', () => {
|
||||
render(<MNotificationRow notification={buildNotification()} />);
|
||||
|
||||
fireEvent.click(screen.getByText('notif.title'));
|
||||
|
||||
expect(markRead).toHaveBeenCalledWith(3);
|
||||
});
|
||||
|
||||
it('FE-MOB-NROW-007: tapping an already-read row does nothing', () => {
|
||||
render(<MNotificationRow notification={buildNotification({ is_read: true })} />);
|
||||
|
||||
fireEvent.click(screen.getByText('notif.title'));
|
||||
|
||||
expect(markRead).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-NROW-008: the trailing button deletes without marking read', () => {
|
||||
render(<MNotificationRow notification={buildNotification()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
|
||||
expect(deleteNotification).toHaveBeenCalledWith(3);
|
||||
expect(markRead).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-NROW-009: a boolean notification answers through the chips', async () => {
|
||||
render(
|
||||
<MNotificationRow
|
||||
notification={buildNotification({
|
||||
type: 'boolean', positive_text_key: 'notif.yes', negative_text_key: 'notif.no',
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('notif.yes'));
|
||||
|
||||
await waitFor(() => expect(respondToBoolean).toHaveBeenCalledWith(3, 'positive'));
|
||||
// The chips sit in their own stop-propagation area, so the row is not marked read.
|
||||
expect(markRead).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-NROW-010: an answered boolean notification ignores further taps', () => {
|
||||
render(
|
||||
<MNotificationRow
|
||||
notification={buildNotification({
|
||||
type: 'boolean', positive_text_key: 'notif.yes', negative_text_key: 'notif.no', response: 'negative',
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('notif.no'));
|
||||
fireEvent.click(screen.getByText('notif.yes'));
|
||||
|
||||
expect(respondToBoolean).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-NROW-011: a navigate notification marks read and follows its target', async () => {
|
||||
render(
|
||||
<MNotificationRow
|
||||
notification={buildNotification({
|
||||
type: 'navigate', navigate_text_key: 'notif.open', navigate_target: '/trips/9',
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('notif.open'));
|
||||
|
||||
await waitFor(() => expect(markRead).toHaveBeenCalledWith(3));
|
||||
});
|
||||
|
||||
it('FE-MOB-NROW-012: a boolean notification without labels renders no chips', () => {
|
||||
render(
|
||||
<MNotificationRow notification={buildNotification({ type: 'boolean', positive_text_key: 'notif.yes' })} />,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('notif.yes')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
// FE-MOB-SETAIR-001 onwards
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { render, screen, waitFor } from '../../../helpers/render';
|
||||
import { server } from '../../../helpers/msw/server';
|
||||
import { resetAllStores } from '../../../helpers/store';
|
||||
import { ToastContainer } from '../../../../src/components/shared/Toast';
|
||||
import MAirTrailConnectionSection from '../../../../src/mobile/screens/settings/MAirTrailConnectionSection';
|
||||
|
||||
interface SavedBody {
|
||||
url: string;
|
||||
apiKey?: string;
|
||||
allowInsecureTls?: boolean;
|
||||
writeEnabled?: boolean;
|
||||
}
|
||||
|
||||
function stubSettings(over: Record<string, unknown> = {}) {
|
||||
server.use(
|
||||
http.get('/api/integrations/airtrail/settings', () =>
|
||||
HttpResponse.json({ url: '', allowInsecureTls: false, writeEnabled: false, connected: false, ...over }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function renderSection() {
|
||||
return render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<MAirTrailConnectionSection />
|
||||
</>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('MAirTrailConnectionSection', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
stubSettings();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETAIR-001: hydrates the form from the stored connection', async () => {
|
||||
stubSettings({ url: 'https://air.example.com', allowInsecureTls: true, writeEnabled: true, connected: true });
|
||||
renderSection();
|
||||
|
||||
expect(await screen.findByDisplayValue('https://air.example.com')).toBeInTheDocument();
|
||||
expect(screen.getByRole('switch', { name: 'Allow self-signed certificates' })).toHaveAttribute('aria-checked', 'true');
|
||||
expect(screen.getByRole('switch', { name: 'Write changes back to AirTrail' })).toHaveAttribute('aria-checked', 'true');
|
||||
expect(screen.getByText('Connected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETAIR-002: an unconfigured instance renders empty and disconnected', async () => {
|
||||
renderSection();
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /Test connection/ })).toBeDisabled());
|
||||
expect(screen.getByText('Not connected')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Bearer API key')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETAIR-003: the stored key is masked by the placeholder, never prefilled', async () => {
|
||||
stubSettings({ url: 'https://air.example.com', connected: true });
|
||||
renderSection();
|
||||
|
||||
const keyInput = await screen.findByPlaceholderText('••••••••');
|
||||
expect(keyInput).toHaveValue('');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETAIR-004: a failing settings fetch still ends the loading state', async () => {
|
||||
server.use(
|
||||
http.get('/api/integrations/airtrail/settings', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
|
||||
);
|
||||
renderSection();
|
||||
|
||||
// Save stays disabled because there is no URL, but the buttons are rendered
|
||||
// rather than stuck behind `loading`.
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /Test connection/ })).toBeDisabled());
|
||||
expect(screen.getByText('Not connected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETAIR-005: Save stays disabled until a URL and a key are present', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSection();
|
||||
|
||||
const saveBtn = await screen.findByRole('button', { name: /Save/ });
|
||||
expect(saveBtn).toBeDisabled();
|
||||
|
||||
await user.type(screen.getByPlaceholderText('https://airtrail.example.com'), 'https://air.example.com');
|
||||
expect(saveBtn).toBeDisabled();
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Bearer API key'), 'secret');
|
||||
expect(saveBtn).toBeEnabled();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETAIR-006: an already-connected instance can be saved without retyping the key', async () => {
|
||||
const user = userEvent.setup();
|
||||
let body: SavedBody | undefined;
|
||||
stubSettings({ url: 'https://air.example.com', connected: true });
|
||||
server.use(
|
||||
http.put('/api/integrations/airtrail/settings', async ({ request }) => {
|
||||
body = (await request.json()) as SavedBody;
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
http.get('/api/integrations/airtrail/status', () => HttpResponse.json({ connected: true })),
|
||||
);
|
||||
renderSection();
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: /Save/ }));
|
||||
await screen.findByText('AirTrail connection saved');
|
||||
expect(body).toEqual({ url: 'https://air.example.com', allowInsecureTls: false, writeEnabled: false });
|
||||
});
|
||||
|
||||
it('FE-MOB-SETAIR-007: a typed key and the toggles are sent along and the field is cleared', async () => {
|
||||
const user = userEvent.setup();
|
||||
let body: SavedBody | undefined;
|
||||
server.use(
|
||||
http.put('/api/integrations/airtrail/settings', async ({ request }) => {
|
||||
body = (await request.json()) as SavedBody;
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
http.get('/api/integrations/airtrail/status', () => HttpResponse.json({ connected: true })),
|
||||
);
|
||||
renderSection();
|
||||
|
||||
await user.type(await screen.findByPlaceholderText('https://airtrail.example.com'), ' https://air.example.com ');
|
||||
await user.type(screen.getByPlaceholderText('Bearer API key'), ' tok-123 ');
|
||||
await user.click(screen.getByRole('switch', { name: 'Allow self-signed certificates' }));
|
||||
await user.click(screen.getByRole('switch', { name: 'Write changes back to AirTrail' }));
|
||||
await user.click(screen.getByRole('button', { name: /Save/ }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(body).toEqual({
|
||||
url: 'https://air.example.com',
|
||||
allowInsecureTls: true,
|
||||
writeEnabled: true,
|
||||
apiKey: 'tok-123',
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(screen.getByPlaceholderText('••••••••')).toHaveValue(''));
|
||||
expect(screen.getByText('Connected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETAIR-008: a warning from the server replaces the success toast', async () => {
|
||||
const user = userEvent.setup();
|
||||
stubSettings({ url: 'https://air.example.com', connected: true });
|
||||
server.use(
|
||||
http.put('/api/integrations/airtrail/settings', () => HttpResponse.json({ warning: 'TLS verification is off' })),
|
||||
http.get('/api/integrations/airtrail/status', () => HttpResponse.json({ connected: true })),
|
||||
);
|
||||
renderSection();
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: /Save/ }));
|
||||
await screen.findByText('TLS verification is off');
|
||||
expect(screen.queryByText('AirTrail connection saved')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETAIR-009: a failing status probe after a successful save marks it disconnected', async () => {
|
||||
const user = userEvent.setup();
|
||||
stubSettings({ url: 'https://air.example.com', connected: true });
|
||||
server.use(
|
||||
http.put('/api/integrations/airtrail/settings', () => HttpResponse.json({ ok: true })),
|
||||
http.get('/api/integrations/airtrail/status', () => HttpResponse.json({ error: 'down' }, { status: 500 })),
|
||||
);
|
||||
renderSection();
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: /Save/ }));
|
||||
await screen.findByText('Not connected');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETAIR-010: a rejected save surfaces the server error message', async () => {
|
||||
const user = userEvent.setup();
|
||||
stubSettings({ url: 'https://air.example.com', connected: true });
|
||||
server.use(
|
||||
http.put('/api/integrations/airtrail/settings', () =>
|
||||
HttpResponse.json({ error: 'Instance unreachable' }, { status: 400 }),
|
||||
),
|
||||
);
|
||||
renderSection();
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: /Save/ }));
|
||||
await screen.findByText('Instance unreachable');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETAIR-011: a successful test reports the flight count and flips the badge', async () => {
|
||||
const user = userEvent.setup();
|
||||
stubSettings({ url: 'https://air.example.com' });
|
||||
server.use(
|
||||
http.post('/api/integrations/airtrail/test', () => HttpResponse.json({ connected: true, flightCount: 12 })),
|
||||
);
|
||||
renderSection();
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: /Test connection/ }));
|
||||
await screen.findByText('Connected — 12 flight(s) found');
|
||||
expect(screen.getByText('Connected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETAIR-012: a rejected test shows the returned error and keeps the badge off', async () => {
|
||||
const user = userEvent.setup();
|
||||
stubSettings({ url: 'https://air.example.com' });
|
||||
server.use(
|
||||
http.post('/api/integrations/airtrail/test', () => HttpResponse.json({ connected: false, error: 'Bad token' })),
|
||||
);
|
||||
renderSection();
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: /Test connection/ }));
|
||||
await screen.findByText('Bad token');
|
||||
expect(screen.getByText('Not connected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETAIR-013: a network failure during the test falls back to the generic message', async () => {
|
||||
const user = userEvent.setup();
|
||||
stubSettings({ url: 'https://air.example.com' });
|
||||
server.use(
|
||||
http.post('/api/integrations/airtrail/test', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
|
||||
);
|
||||
renderSection();
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: /Test connection/ }));
|
||||
await screen.findByText('Connection failed');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
// FE-MOB-SETLLM-001 onwards
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render, screen, waitFor } from '../../../helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store';
|
||||
import { buildSettings } from '../../../helpers/factories';
|
||||
import { useSettingsStore } from '../../../../src/store/settingsStore';
|
||||
import { ToastContainer } from '../../../../src/components/shared/Toast';
|
||||
import type { Settings } from '../../../../src/types';
|
||||
import MLlmConnectionSection from '../../../../src/mobile/screens/settings/MLlmConnectionSection';
|
||||
|
||||
function seedLlm(over: Partial<Settings> = {}, updateSettings = vi.fn().mockResolvedValue(undefined)) {
|
||||
seedStore(useSettingsStore, {
|
||||
settings: buildSettings({ language: 'en', ...over }),
|
||||
isLoaded: true,
|
||||
updateSettings,
|
||||
});
|
||||
return updateSettings;
|
||||
}
|
||||
|
||||
function renderSection() {
|
||||
return render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<MLlmConnectionSection />
|
||||
</>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('MLlmConnectionSection', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedLlm();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETLLM-001: defaults to the local provider with a base URL and no key field', () => {
|
||||
renderSection();
|
||||
|
||||
expect(screen.getByText('AI parsing')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Local \(Ollama\)/ })).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('http://localhost:11434')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Stored encrypted. Leave blank to keep the current key.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETLLM-002: hydrates provider, model, base URL and the multimodal switch', () => {
|
||||
seedLlm({
|
||||
llm_provider: 'openai',
|
||||
llm_model: 'gpt-4o-mini',
|
||||
llm_base_url: 'https://api.openai.com/v1',
|
||||
llm_multimodal: true,
|
||||
});
|
||||
renderSection();
|
||||
|
||||
expect(screen.getByRole('button', { name: /OpenAI/ })).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('gpt-4o-mini')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('https://api.openai.com/v1')).toBeInTheDocument();
|
||||
expect(screen.getByRole('switch', { name: 'Send documents as images' })).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETLLM-003: nothing is hydrated while the settings are still loading', () => {
|
||||
seedStore(useSettingsStore, {
|
||||
settings: buildSettings({ language: 'en', llm_provider: 'anthropic', llm_model: 'claude' }),
|
||||
isLoaded: false,
|
||||
});
|
||||
renderSection();
|
||||
|
||||
expect(screen.getByRole('button', { name: /Local \(Ollama\)/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Save/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETLLM-004: a stored key drives the masked placeholder and is never prefilled', () => {
|
||||
seedLlm({ llm_provider: 'anthropic', llm_api_key: '***' });
|
||||
renderSection();
|
||||
|
||||
expect(screen.getByPlaceholderText('••••••••')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETLLM-005: without a stored key the field falls back to the plain label placeholder', () => {
|
||||
seedLlm({ llm_provider: 'anthropic' });
|
||||
renderSection();
|
||||
|
||||
expect(screen.getByPlaceholderText('API key')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETLLM-006: picking Anthropic hides the base URL and reveals the key field', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSection();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Local \(Ollama\)/ }));
|
||||
await user.click(await screen.findByRole('button', { name: 'Anthropic' }));
|
||||
|
||||
expect(screen.queryByPlaceholderText('http://localhost:11434')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Stored encrypted. Leave blank to keep the current key.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETLLM-007: picking OpenAI keeps the base URL and adds the key field', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSection();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Local \(Ollama\)/ }));
|
||||
await user.click(await screen.findByRole('button', { name: 'OpenAI' }));
|
||||
|
||||
expect(screen.getByPlaceholderText('http://localhost:11434')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('API key')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETLLM-008: saving trims the inputs and omits the key when none was typed', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSettings = seedLlm({ llm_model: '', llm_base_url: '' });
|
||||
renderSection();
|
||||
|
||||
await user.type(screen.getByPlaceholderText('qwen3:8b'), ' qwen3:8b ');
|
||||
await user.type(screen.getByPlaceholderText('http://localhost:11434'), ' http://ollama.local ');
|
||||
await user.click(screen.getByRole('button', { name: /Save/ }));
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledWith({
|
||||
llm_provider: 'local',
|
||||
llm_model: 'qwen3:8b',
|
||||
llm_base_url: 'http://ollama.local',
|
||||
llm_multimodal: false,
|
||||
});
|
||||
await screen.findByText('AI settings saved');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETLLM-009: a typed key is sent, the field is cleared and the mask takes over', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSettings = seedLlm({ llm_provider: 'anthropic', llm_model: 'claude-sonnet' });
|
||||
renderSection();
|
||||
|
||||
await user.type(screen.getByPlaceholderText('API key'), ' sk-test ');
|
||||
await user.click(screen.getByRole('button', { name: /Save/ }));
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({ llm_api_key: 'sk-test' }));
|
||||
await waitFor(() => expect(screen.getByPlaceholderText('••••••••')).toHaveValue(''));
|
||||
});
|
||||
|
||||
it('FE-MOB-SETLLM-010: a non-local provider never persists a base URL', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSettings = seedLlm({ llm_provider: 'anthropic', llm_base_url: 'http://leftover' });
|
||||
renderSection();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Save/ }));
|
||||
expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({ llm_base_url: '' }));
|
||||
});
|
||||
|
||||
it('FE-MOB-SETLLM-011: the multimodal switch is part of the saved payload', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSettings = seedLlm();
|
||||
renderSection();
|
||||
|
||||
await user.click(screen.getByRole('switch', { name: 'Send documents as images' }));
|
||||
await user.click(screen.getByRole('button', { name: /Save/ }));
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({ llm_multimodal: true }));
|
||||
});
|
||||
|
||||
it('FE-MOB-SETLLM-012: a failing save shows the error toast', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedLlm({}, vi.fn().mockRejectedValue(new Error('nope')));
|
||||
renderSection();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Save/ }));
|
||||
await screen.findByText('Could not save AI settings');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
// FE-MOB-SETDORD-001 onwards
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render, screen } from '../../../helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store';
|
||||
import { useAddonStore } from '../../../../src/store/addonStore';
|
||||
import { DEFAULT_APPEARANCE, type AppearanceConfig, type MobileDashToken } from '@trek/shared';
|
||||
import MMobileDashOrder from '../../../../src/mobile/screens/settings/MMobileDashOrder';
|
||||
|
||||
function buildCfg(over: Partial<AppearanceConfig['dashboard']> = {}): AppearanceConfig {
|
||||
return {
|
||||
...DEFAULT_APPEARANCE,
|
||||
dashboard: { ...DEFAULT_APPEARANCE.dashboard, ...over },
|
||||
};
|
||||
}
|
||||
|
||||
function enableCollections() {
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'collections', name: 'Collections', type: 'global', icon: '', enabled: true }],
|
||||
loaded: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** The label + badge wrapper of one block row. */
|
||||
function blockRow(label: string): HTMLElement {
|
||||
return screen.getByText(label).parentElement!;
|
||||
}
|
||||
|
||||
/** The visible block labels in DOM order. */
|
||||
function rowLabels(): string[] {
|
||||
return screen.getAllByLabelText('Move up').map((btn) => {
|
||||
const row = btn.closest('div')!;
|
||||
return row.querySelector('span > span')!.textContent ?? '';
|
||||
});
|
||||
}
|
||||
|
||||
describe('MMobileDashOrder', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
enableCollections();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETDORD-001: renders the built-in order when nothing is stored', () => {
|
||||
render(<MMobileDashOrder cfg={buildCfg()} onChange={vi.fn()} />);
|
||||
|
||||
expect(rowLabels()).toEqual(['Trips', 'Currency', 'Collections', 'Timezones', 'Upcoming reservations']);
|
||||
});
|
||||
|
||||
it('FE-MOB-SETDORD-002: a stored order wins and missing tokens are appended', () => {
|
||||
render(<MMobileDashOrder cfg={buildCfg({ mobileOrder: ['timezones', 'trips'] })} onChange={vi.fn()} />);
|
||||
|
||||
expect(rowLabels()).toEqual(['Timezones', 'Trips', 'Currency', 'Collections', 'Upcoming reservations']);
|
||||
});
|
||||
|
||||
it('FE-MOB-SETDORD-003: moving a block down emits the reordered token list', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn<(order: MobileDashToken[]) => void>();
|
||||
render(<MMobileDashOrder cfg={buildCfg()} onChange={onChange} />);
|
||||
|
||||
await user.click(screen.getAllByLabelText('Move down')[0]);
|
||||
expect(onChange).toHaveBeenCalledWith(['currency', 'trips', 'collections', 'timezones', 'upcomingReservations']);
|
||||
});
|
||||
|
||||
it('FE-MOB-SETDORD-004: moving a block up swaps it with its predecessor', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn<(order: MobileDashToken[]) => void>();
|
||||
render(<MMobileDashOrder cfg={buildCfg()} onChange={onChange} />);
|
||||
|
||||
await user.click(screen.getAllByLabelText('Move up')[2]);
|
||||
expect(onChange).toHaveBeenCalledWith(['trips', 'collections', 'currency', 'timezones', 'upcomingReservations']);
|
||||
});
|
||||
|
||||
it('FE-MOB-SETDORD-005: the first row cannot move up and the last cannot move down', () => {
|
||||
render(<MMobileDashOrder cfg={buildCfg()} onChange={vi.fn()} />);
|
||||
|
||||
const ups = screen.getAllByLabelText('Move up');
|
||||
const downs = screen.getAllByLabelText('Move down');
|
||||
expect(ups[0]).toBeDisabled();
|
||||
expect(ups[1]).not.toBeDisabled();
|
||||
expect(downs[downs.length - 1]).toBeDisabled();
|
||||
expect(downs[0]).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETDORD-006: a widget switched off is marked hidden, trips never is', () => {
|
||||
const cfg = buildCfg({
|
||||
mobile: { ...DEFAULT_APPEARANCE.dashboard.mobile, currency: false, timezones: false },
|
||||
});
|
||||
render(<MMobileDashOrder cfg={cfg} onChange={vi.fn()} />);
|
||||
|
||||
expect(screen.getAllByText('Hidden')).toHaveLength(2);
|
||||
const tripsRow = blockRow('Trips');
|
||||
expect(tripsRow.textContent).not.toContain('Hidden');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETDORD-007: collections counts as hidden while the addon is off', () => {
|
||||
seedStore(useAddonStore, { addons: [], loaded: true });
|
||||
render(<MMobileDashOrder cfg={buildCfg()} onChange={vi.fn()} />);
|
||||
|
||||
const collectionsRow = blockRow('Collections');
|
||||
expect(collectionsRow.textContent).toContain('Hidden');
|
||||
expect(screen.getAllByText('Hidden')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-SETDORD-008: upcoming reservations follows its own mobile flag', () => {
|
||||
const cfg = buildCfg({
|
||||
mobile: { ...DEFAULT_APPEARANCE.dashboard.mobile, upcomingReservations: false },
|
||||
});
|
||||
render(<MMobileDashOrder cfg={cfg} onChange={vi.fn()} />);
|
||||
|
||||
const row = blockRow('Upcoming reservations');
|
||||
expect(row.textContent).toContain('Hidden');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
// FE-MOB-SETNAVC-001 onwards
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { act, render, screen, within } from '../../../helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store';
|
||||
import { useAddonStore } from '../../../../src/store/addonStore';
|
||||
import { usePluginStore } from '../../../../src/store/pluginStore';
|
||||
import type { MobileNavValue } from '../../../../src/components/Settings/useMobileNavEditor';
|
||||
import MMobileNavCustomizer from '../../../../src/mobile/screens/settings/MMobileNavCustomizer';
|
||||
|
||||
const GLOBAL_ADDONS = [
|
||||
{ id: 'vacay', name: 'Vacay', type: 'global', icon: 'calendar', enabled: true },
|
||||
{ id: 'atlas', name: 'Atlas', type: 'global', icon: 'map', enabled: true },
|
||||
{ id: 'journey', name: 'Journey', type: 'global', icon: 'compass', enabled: true },
|
||||
{ id: 'collections', name: 'Collections', type: 'global', icon: 'bookmark', enabled: true },
|
||||
];
|
||||
|
||||
/** The section wrapper holding the rows of a zone ("In the bar" / Under "More"). */
|
||||
function zone(heading: string): HTMLElement {
|
||||
return screen.getByText(heading).closest('div')!.parentElement!;
|
||||
}
|
||||
|
||||
function rowLabels(heading: string): string[] {
|
||||
return within(zone(heading))
|
||||
.getAllByLabelText('Move up')
|
||||
.map((btn) => btn.closest('div')!.querySelector('span.truncate')!.textContent ?? '');
|
||||
}
|
||||
|
||||
describe('MMobileNavCustomizer', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useAddonStore, { addons: GLOBAL_ADDONS, loaded: true });
|
||||
usePluginStore.setState({ plugins: [], loaded: true });
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNAVC-001: an uncustomised value falls back to the built-in dock split', () => {
|
||||
render(<MMobileNavCustomizer value={{ bar: [], more: [] }} onChange={vi.fn()} />);
|
||||
|
||||
expect(rowLabels('In the bar')).toEqual(['Vacay', 'Atlas']);
|
||||
expect(rowLabels('Under “More”')).toEqual(['Journey', 'Collections']);
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNAVC-002: Dashboard is listed pinned and has no reorder buttons', () => {
|
||||
render(<MMobileNavCustomizer value={{ bar: [], more: [] }} onChange={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText('My Trips')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pinned')).toBeInTheDocument();
|
||||
// Dashboard sits in the bar but is not one of the movable rows.
|
||||
expect(rowLabels('In the bar')).not.toContain('My Trips');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNAVC-003: the bar counter includes the pinned Dashboard slot', () => {
|
||||
render(<MMobileNavCustomizer value={{ bar: ['vacay'], more: ['atlas'] }} onChange={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText('2/3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNAVC-004: demoting a bar item emits it at the end of More', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn<(next: MobileNavValue) => void>();
|
||||
render(<MMobileNavCustomizer value={{ bar: ['vacay', 'atlas'], more: ['journey', 'collections'] }} onChange={onChange} />);
|
||||
|
||||
await user.click(within(zone('In the bar')).getAllByLabelText('Move under “More”')[0]);
|
||||
expect(onChange).toHaveBeenCalledWith({ bar: ['atlas'], more: ['journey', 'collections', 'vacay'] });
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNAVC-005: promoting a More item appends it to the bar', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn<(next: MobileNavValue) => void>();
|
||||
render(<MMobileNavCustomizer value={{ bar: ['vacay'], more: ['journey', 'atlas', 'collections'] }} onChange={onChange} />);
|
||||
|
||||
await user.click(within(zone('Under “More”')).getAllByLabelText('Move into the bar')[1]);
|
||||
expect(onChange).toHaveBeenCalledWith({ bar: ['vacay', 'atlas'], more: ['journey', 'collections'] });
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNAVC-006: promotion is disabled once the bar is full', () => {
|
||||
render(<MMobileNavCustomizer value={{ bar: ['vacay', 'atlas'], more: ['journey', 'collections'] }} onChange={vi.fn()} />);
|
||||
|
||||
for (const btn of within(zone('Under “More”')).getAllByLabelText('Move into the bar')) {
|
||||
expect(btn).toBeDisabled();
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNAVC-007: reordering inside the bar emits the swapped id list', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn<(next: MobileNavValue) => void>();
|
||||
render(<MMobileNavCustomizer value={{ bar: ['vacay', 'atlas'], more: ['journey', 'collections'] }} onChange={onChange} />);
|
||||
|
||||
await user.click(within(zone('In the bar')).getAllByLabelText('Move up')[1]);
|
||||
expect(onChange).toHaveBeenCalledWith({ bar: ['atlas', 'vacay'], more: ['journey', 'collections'] });
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNAVC-008: reordering inside More leaves the bar untouched', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn<(next: MobileNavValue) => void>();
|
||||
render(<MMobileNavCustomizer value={{ bar: ['vacay'], more: ['journey', 'atlas', 'collections'] }} onChange={onChange} />);
|
||||
|
||||
await user.click(within(zone('Under “More”')).getAllByLabelText('Move down')[0]);
|
||||
expect(onChange).toHaveBeenCalledWith({ bar: ['vacay'], more: ['atlas', 'journey', 'collections'] });
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNAVC-009: the ends of a zone have their arrows disabled', () => {
|
||||
render(<MMobileNavCustomizer value={{ bar: ['vacay', 'atlas'], more: ['journey', 'collections'] }} onChange={vi.fn()} />);
|
||||
|
||||
const ups = within(zone('In the bar')).getAllByLabelText('Move up');
|
||||
const downs = within(zone('In the bar')).getAllByLabelText('Move down');
|
||||
expect(ups[0]).toBeDisabled();
|
||||
expect(downs[1]).toBeDisabled();
|
||||
expect(ups[1]).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNAVC-010: an empty More zone shows the placeholder instead of rows', () => {
|
||||
seedStore(useAddonStore, { addons: GLOBAL_ADDONS.slice(0, 2), loaded: true });
|
||||
render(<MMobileNavCustomizer value={{ bar: ['vacay', 'atlas'], more: [] }} onChange={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText('Nothing here yet — everything fits in the bar.')).toBeInTheDocument();
|
||||
expect(within(zone('Under “More”')).queryByLabelText('Move up')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNAVC-011: the preview mirrors the dock and only shows More when items are demoted', () => {
|
||||
const { rerender } = render(
|
||||
<MMobileNavCustomizer value={{ bar: ['vacay', 'atlas'], more: ['journey'] }} onChange={vi.fn()} />,
|
||||
);
|
||||
expect(screen.getByTitle('More')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('My Trips')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Vacay')).toBeInTheDocument();
|
||||
|
||||
act(() => seedStore(useAddonStore, { addons: GLOBAL_ADDONS.slice(0, 2), loaded: true }));
|
||||
rerender(<MMobileNavCustomizer value={{ bar: ['vacay', 'atlas'], more: [] }} onChange={vi.fn()} />);
|
||||
expect(screen.queryByTitle('More')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNAVC-012: page plugins appear as nav items under More', () => {
|
||||
usePluginStore.setState({
|
||||
plugins: [{ id: 'todos', name: 'Trip To-Dos', type: 'page', icon: 'list' }] as never,
|
||||
loaded: true,
|
||||
});
|
||||
render(<MMobileNavCustomizer value={{ bar: ['vacay'], more: ['plugin:todos'] }} onChange={vi.fn()} />);
|
||||
|
||||
expect(rowLabels('Under “More”')).toContain('Trip To-Dos');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,351 @@
|
||||
// FE-MOB-PHOTOPROV-001 onwards
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { render, screen, waitFor, within } from '../../../helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store';
|
||||
import { buildUser } from '../../../helpers/factories';
|
||||
import { server } from '../../../helpers/msw/server';
|
||||
import { useAuthStore } from '../../../../src/store/authStore';
|
||||
import { useAddonStore } from '../../../../src/store/addonStore';
|
||||
import { ToastContainer } from '../../../../src/components/shared/Toast';
|
||||
import MPhotoProvidersSection from '../../../../src/mobile/screens/settings/MPhotoProvidersSection';
|
||||
|
||||
// Fields are deliberately declared out of sort_order so the component's own
|
||||
// sorting is what puts them on screen in the url → api_key → skip_ssl order.
|
||||
const immich = {
|
||||
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: 'skip_ssl', label: 'skipSSLVerification', input_type: 'checkbox', required: false, secret: false, settings_key: 'skip_ssl', payload_key: 'skip_ssl', sort_order: 3 },
|
||||
{ key: 'api_key', label: 'providerApiKey', input_type: 'text', placeholder: null, required: true, secret: true, settings_key: 'api_key', payload_key: 'api_key', sort_order: 2 },
|
||||
{ key: 'url', label: 'providerUrl', input_type: 'text', placeholder: 'https://immich.example.com', hint: 'providerUrlHintSynology', required: true, secret: false, settings_key: 'url', payload_key: 'url', sort_order: 1 },
|
||||
],
|
||||
};
|
||||
|
||||
// Only a GET-shaped test route and no settings_put / status_get: Save must stay
|
||||
// dead and the connection test has to fall back to the GET endpoint.
|
||||
const piwigo = {
|
||||
id: 'piwigo',
|
||||
name: 'Piwigo',
|
||||
type: 'photo_provider',
|
||||
enabled: true,
|
||||
config: {
|
||||
settings_get: '/addons/piwigo/settings',
|
||||
test_get: '/addons/piwigo/test',
|
||||
},
|
||||
fields: [
|
||||
{ key: 'auto_upload', label: 'immichAutoUpload', input_type: 'checkbox', required: false, secret: false, settings_key: 'auto_upload', payload_key: 'auto_upload', sort_order: 1 },
|
||||
{ key: 'username', label: 'providerUsername', input_type: 'text', placeholder: 'user', required: false, secret: false, settings_key: 'username', payload_key: 'username', sort_order: 2 },
|
||||
],
|
||||
};
|
||||
|
||||
// Config values of the wrong type must be ignored, leaving the provider with no
|
||||
// usable route at all — neither button may be clickable.
|
||||
const bare = {
|
||||
id: 'bare',
|
||||
name: '',
|
||||
type: 'photo_provider',
|
||||
enabled: true,
|
||||
config: { settings_get: 7, settings_put: null, status_get: false },
|
||||
fields: [
|
||||
{ key: 'token', label: 'providerPassword', input_type: 'text', required: false, secret: true, settings_key: null, payload_key: null, sort_order: 1 },
|
||||
],
|
||||
};
|
||||
|
||||
type Addon = { id: string; type: string; enabled: boolean };
|
||||
|
||||
function seedProviders(providers: unknown[]): void {
|
||||
seedStore(useAddonStore, { addons: providers as Addon[] });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
seedStore(useAddonStore, { addons: [] });
|
||||
server.use(
|
||||
http.get('/api/addons/immich/settings', () =>
|
||||
HttpResponse.json({ url: 'https://photos.example.com', api_key: 'never-sent-back', skip_ssl: 1, 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 })),
|
||||
http.get('/api/addons/piwigo/settings', () => HttpResponse.json({})),
|
||||
http.get('/api/addons/piwigo/test', () => HttpResponse.json({ connected: true })),
|
||||
);
|
||||
});
|
||||
|
||||
describe('MPhotoProvidersSection', () => {
|
||||
it('FE-MOB-PHOTOPROV-001: renders nothing while the memories addon is off', () => {
|
||||
const { container } = render(<MPhotoProvidersSection />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('FE-MOB-PHOTOPROV-002: renders nothing when memories is on but no provider is active', async () => {
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ ...piwigo, enabled: false }] as unknown as Addon[],
|
||||
isEnabled: () => true,
|
||||
});
|
||||
const { container } = render(<MPhotoProvidersSection />);
|
||||
await waitFor(() => expect(container).toBeEmptyDOMElement());
|
||||
expect(screen.queryByText('Piwigo')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-PHOTOPROV-003: renders one card per provider with the fields in sort_order', async () => {
|
||||
seedProviders([immich, piwigo]);
|
||||
render(<MPhotoProvidersSection />);
|
||||
|
||||
await screen.findByText('Immich');
|
||||
expect(screen.getByText('Piwigo')).toBeInTheDocument();
|
||||
|
||||
const immichCard = screen.getByText('Immich').closest('section') as HTMLElement;
|
||||
const labels = within(immichCard)
|
||||
.getAllByText(/Server URL|API Key|Skip SSL certificate verification/)
|
||||
.map(el => el.textContent);
|
||||
expect(labels).toEqual(['Server URL', 'API Key', 'Skip SSL certificate verification']);
|
||||
// The hint of the url field is translated, not printed as its key.
|
||||
expect(screen.getByText(/Include the Photos app path in the URL/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-PHOTOPROV-004: hydrates non-secret fields, leaves the secret one blank and coerces non-strings', async () => {
|
||||
seedProviders([immich]);
|
||||
render(<MPhotoProvidersSection />);
|
||||
|
||||
const urlInput = await screen.findByDisplayValue('https://photos.example.com');
|
||||
expect(urlInput).toBeInTheDocument();
|
||||
// The API key came back from the server but must never be prefilled.
|
||||
expect(screen.queryByDisplayValue('never-sent-back')).not.toBeInTheDocument();
|
||||
// skip_ssl arrived as the number 1 — stringified, so it is not the literal 'true'.
|
||||
expect(screen.getByRole('switch', { name: 'Skip SSL certificate verification' })).toHaveAttribute('aria-checked', 'false');
|
||||
});
|
||||
|
||||
it('FE-MOB-PHOTOPROV-005: missing settings values fall back to empty text and an off checkbox', async () => {
|
||||
seedProviders([piwigo]);
|
||||
render(<MPhotoProvidersSection />);
|
||||
|
||||
await screen.findByText('Piwigo');
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('switch', { name: 'Mirror journey photos to Immich on upload' })).toHaveAttribute('aria-checked', 'false'),
|
||||
);
|
||||
expect(screen.getByPlaceholderText('user')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('FE-MOB-PHOTOPROV-006: a connected provider masks the empty secret field', 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 })),
|
||||
);
|
||||
seedProviders([immich]);
|
||||
render(<MPhotoProvidersSection />);
|
||||
|
||||
await screen.findByText('Immich');
|
||||
await waitFor(() => expect(screen.getByPlaceholderText('••••••••')).toBeInTheDocument());
|
||||
expect(screen.getByText('Connected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-PHOTOPROV-007: Save stays disabled until every required field has a value', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedProviders([immich]);
|
||||
render(<MPhotoProvidersSection />);
|
||||
|
||||
await screen.findByDisplayValue('https://photos.example.com');
|
||||
const save = screen.getByRole('button', { name: 'Save' });
|
||||
expect(save).toBeDisabled();
|
||||
|
||||
await user.type(screen.getByPlaceholderText(''), ' fresh-key ');
|
||||
await waitFor(() => expect(save).toBeEnabled());
|
||||
});
|
||||
|
||||
it('FE-MOB-PHOTOPROV-008: Save trims values, sends the checkbox as a boolean and re-reads the status', async () => {
|
||||
const user = userEvent.setup();
|
||||
let body: Record<string, unknown> | null = null;
|
||||
let statusCalls = 0;
|
||||
server.use(
|
||||
http.put('/api/addons/immich/settings', async ({ request }) => {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
http.get('/api/addons/immich/status', () => {
|
||||
statusCalls += 1;
|
||||
return HttpResponse.json({ connected: true });
|
||||
}),
|
||||
);
|
||||
seedProviders([immich]);
|
||||
render(<MPhotoProvidersSection />);
|
||||
|
||||
await screen.findByDisplayValue('https://photos.example.com');
|
||||
await user.type(screen.getByPlaceholderText(''), ' fresh-key ');
|
||||
await user.click(screen.getByRole('switch', { name: 'Skip SSL certificate verification' }));
|
||||
const save = screen.getByRole('button', { name: 'Save' });
|
||||
await waitFor(() => expect(save).toBeEnabled());
|
||||
await user.click(save);
|
||||
|
||||
await waitFor(() => expect(body).not.toBeNull());
|
||||
expect(body).toEqual({ url: 'https://photos.example.com', api_key: 'fresh-key', skip_ssl: true });
|
||||
// The status endpoint is polled once on mount and again right after the save.
|
||||
await waitFor(() => expect(statusCalls).toBeGreaterThanOrEqual(2));
|
||||
expect(await screen.findByText('Connected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-PHOTOPROV-009: an untouched secret field is left out of the payload entirely', async () => {
|
||||
const user = userEvent.setup();
|
||||
let body: Record<string, unknown> | null = null;
|
||||
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 })),
|
||||
http.put('/api/addons/immich/settings', async ({ request }) => {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
// A provider whose secret is optional, so Save is reachable without typing one.
|
||||
seedProviders([{ ...immich, fields: immich.fields.map(f => ({ ...f, required: f.key === 'url' })) }]);
|
||||
render(<MPhotoProvidersSection />);
|
||||
|
||||
await screen.findByDisplayValue('https://photos.example.com');
|
||||
const save = screen.getByRole('button', { name: 'Save' });
|
||||
await waitFor(() => expect(save).toBeEnabled());
|
||||
await user.click(save);
|
||||
|
||||
await waitFor(() => expect(body).not.toBeNull());
|
||||
expect(body).toEqual({ url: 'https://photos.example.com', skip_ssl: false });
|
||||
});
|
||||
|
||||
it('FE-MOB-PHOTOPROV-010: a successful save toasts the provider name', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedProviders([{ ...immich, fields: [immich.fields[2]] }]);
|
||||
render(<><ToastContainer /><MPhotoProvidersSection /></>);
|
||||
|
||||
await screen.findByDisplayValue('https://photos.example.com');
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
expect(await screen.findByText('Immich settings saved')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-PHOTOPROV-011: a rejected save toasts the error and re-enables the button', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(http.put('/api/addons/immich/settings', () => HttpResponse.json({ error: 'nope' }, { status: 500 })));
|
||||
seedProviders([{ ...immich, fields: [immich.fields[2]] }]);
|
||||
render(<><ToastContainer /><MPhotoProvidersSection /></>);
|
||||
|
||||
await screen.findByDisplayValue('https://photos.example.com');
|
||||
const save = screen.getByRole('button', { name: 'Save' });
|
||||
await user.click(save);
|
||||
|
||||
expect(await screen.findByText('Could not save Immich settings')).toBeInTheDocument();
|
||||
await waitFor(() => expect(save).toBeEnabled());
|
||||
});
|
||||
|
||||
it('FE-MOB-PHOTOPROV-012: a passing test flips the badge to Connected and toasts', async () => {
|
||||
const user = userEvent.setup();
|
||||
let payload: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.post('/api/addons/immich/test', async ({ request }) => {
|
||||
payload = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({ connected: true });
|
||||
}),
|
||||
);
|
||||
seedProviders([immich]);
|
||||
render(<><ToastContainer /><MPhotoProvidersSection /></>);
|
||||
|
||||
await screen.findByDisplayValue('https://photos.example.com');
|
||||
expect(screen.getByText('Not connected')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Test' }));
|
||||
|
||||
expect(await screen.findByText('Connected to Immich')).toBeInTheDocument();
|
||||
expect(screen.getByText('Connected')).toBeInTheDocument();
|
||||
// The test posts the form as it stands, not the persisted settings.
|
||||
expect(payload).toEqual({ url: 'https://photos.example.com', skip_ssl: false });
|
||||
});
|
||||
|
||||
it('FE-MOB-PHOTOPROV-013: a test answering connected:false surfaces the server error text', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(http.post('/api/addons/immich/test', () => HttpResponse.json({ connected: false, error: 'Auth failed' })));
|
||||
seedProviders([immich]);
|
||||
render(<><ToastContainer /><MPhotoProvidersSection /></>);
|
||||
|
||||
await screen.findByText('Immich');
|
||||
await user.click(screen.getByRole('button', { name: 'Test' }));
|
||||
|
||||
expect(await screen.findByText(/Could not connect to Immich : Auth failed/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Not connected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-PHOTOPROV-014: a failing test request toasts the generic connection error', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(http.post('/api/addons/immich/test', () => HttpResponse.json({ error: 'boom' }, { status: 500 })));
|
||||
seedProviders([immich]);
|
||||
render(<><ToastContainer /><MPhotoProvidersSection /></>);
|
||||
|
||||
await screen.findByText('Immich');
|
||||
const test = screen.getByRole('button', { name: 'Test' });
|
||||
await user.click(test);
|
||||
|
||||
expect(await screen.findByText('Could not connect to Immich')).toBeInTheDocument();
|
||||
await waitFor(() => expect(test).toBeEnabled());
|
||||
});
|
||||
|
||||
it('FE-MOB-PHOTOPROV-015: a provider with only test_get falls back to the GET route and cannot be saved', async () => {
|
||||
const user = userEvent.setup();
|
||||
let getCalled = false;
|
||||
server.use(
|
||||
http.get('/api/addons/piwigo/test', () => {
|
||||
getCalled = true;
|
||||
return HttpResponse.json({ connected: true });
|
||||
}),
|
||||
);
|
||||
seedProviders([piwigo]);
|
||||
render(<MPhotoProvidersSection />);
|
||||
|
||||
await screen.findByText('Piwigo');
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
|
||||
await user.click(screen.getByRole('button', { name: 'Test' }));
|
||||
|
||||
await waitFor(() => expect(getCalled).toBe(true));
|
||||
expect(await screen.findByText('Connected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-PHOTOPROV-016: config entries of the wrong type leave both actions disabled', async () => {
|
||||
seedProviders([bare]);
|
||||
render(<MPhotoProvidersSection />);
|
||||
|
||||
// No name → the card falls back to the addon id.
|
||||
await screen.findByText('bare');
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Test' })).toBeDisabled();
|
||||
expect(screen.getByText('Not connected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-PHOTOPROV-017: a failing settings load leaves the fields empty instead of crashing', async () => {
|
||||
server.use(
|
||||
http.get('/api/addons/immich/settings', () => HttpResponse.json({ error: 'down' }, { status: 500 })),
|
||||
http.get('/api/addons/immich/status', () => HttpResponse.json({ error: 'down' }, { status: 500 })),
|
||||
);
|
||||
seedProviders([immich]);
|
||||
render(<MPhotoProvidersSection />);
|
||||
|
||||
await screen.findByText('Immich');
|
||||
expect(screen.getByPlaceholderText('https://immich.example.com')).toHaveValue('');
|
||||
await waitFor(() => expect(screen.getByText('Not connected')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-MOB-PHOTOPROV-018: the checkbox toggle flips both ways', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedProviders([immich]);
|
||||
render(<MPhotoProvidersSection />);
|
||||
|
||||
await screen.findByText('Immich');
|
||||
const toggle = screen.getByRole('switch', { name: 'Skip SSL certificate verification' });
|
||||
await user.click(toggle);
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'true');
|
||||
await user.click(toggle);
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'false');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
// FE-MOB-SETSCP-001 onwards
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render, screen } from '../../../helpers/render';
|
||||
import { resetAllStores } from '../../../helpers/store';
|
||||
import { ALL_SCOPES } from '../../../../src/api/oauthScopes';
|
||||
import MScopeGroupPicker from '../../../../src/mobile/screens/settings/MScopeGroupPicker';
|
||||
|
||||
const TRIP_SCOPES = ['trips:read', 'trips:write', 'trips:delete', 'trips:share'];
|
||||
|
||||
describe('MScopeGroupPicker', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETSCP-001: renders one collapsed card per scope group', () => {
|
||||
render(<MScopeGroupPicker selected={[]} onChange={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: /^Trips/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Journey/ })).toBeInTheDocument();
|
||||
// Collapsed: no individual scope rows yet.
|
||||
expect(screen.queryByText('View trips & itineraries')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETSCP-002: Select all emits every known scope', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<MScopeGroupPicker selected={[]} onChange={onChange} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Select all' }));
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange.mock.calls[0][0]).toEqual(ALL_SCOPES);
|
||||
});
|
||||
|
||||
it('FE-MOB-SETSCP-003: with everything selected the button flips to Deselect all and emits []', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<MScopeGroupPicker selected={ALL_SCOPES} onChange={onChange} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Deselect all' }));
|
||||
expect(onChange).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('FE-MOB-SETSCP-004: expanding a group reveals its scope labels and descriptions', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<MScopeGroupPicker selected={[]} onChange={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /^Trips/ }));
|
||||
|
||||
expect(screen.getByText('View trips & itineraries')).toBeInTheDocument();
|
||||
expect(screen.getByText('Read trips, days, day notes, and members')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETSCP-005: expanding twice collapses the group again', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<MScopeGroupPicker selected={[]} onChange={vi.fn()} />);
|
||||
|
||||
const header = screen.getByRole('button', { name: /^Trips/ });
|
||||
await user.click(header);
|
||||
expect(screen.getByText('View trips & itineraries')).toBeInTheDocument();
|
||||
|
||||
await user.click(header);
|
||||
expect(screen.queryByText('View trips & itineraries')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETSCP-006: the group selector adds every scope of that group', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<MScopeGroupPicker selected={['geo:read']} onChange={onChange} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Select all Trips' }));
|
||||
expect(onChange).toHaveBeenCalledWith(['geo:read', ...TRIP_SCOPES]);
|
||||
});
|
||||
|
||||
it('FE-MOB-SETSCP-007: a fully selected group offers deselect and keeps the other groups', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<MScopeGroupPicker selected={[...TRIP_SCOPES, 'geo:read']} onChange={onChange} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Deselect all Trips' }));
|
||||
expect(onChange).toHaveBeenCalledWith(['geo:read']);
|
||||
});
|
||||
|
||||
it('FE-MOB-SETSCP-008: a partially selected group shows the n/total counter', () => {
|
||||
render(<MScopeGroupPicker selected={['trips:read', 'trips:write']} onChange={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText('(2/4)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETSCP-009: tapping an unselected scope row adds it', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<MScopeGroupPicker selected={[]} onChange={onChange} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /^Trips/ }));
|
||||
await user.click(screen.getByText('View trips & itineraries').closest('button')!);
|
||||
expect(onChange).toHaveBeenCalledWith(['trips:read']);
|
||||
});
|
||||
|
||||
it('FE-MOB-SETSCP-010: tapping a selected scope row removes it again', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<MScopeGroupPicker selected={['trips:read', 'trips:write']} onChange={onChange} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /^Trips/ }));
|
||||
await user.click(screen.getByText('View trips & itineraries').closest('button')!);
|
||||
expect(onChange).toHaveBeenCalledWith(['trips:write']);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,400 @@
|
||||
// FE-MOB-SETAPP-001 onwards
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { fireEvent, render, screen, waitFor } from '../../../helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store';
|
||||
import { buildSettings } from '../../../helpers/factories';
|
||||
import { useSettingsStore } from '../../../../src/store/settingsStore';
|
||||
import { useAddonStore } from '../../../../src/store/addonStore';
|
||||
import { usePluginStore } from '../../../../src/store/pluginStore';
|
||||
import { ToastContainer } from '../../../../src/components/shared/Toast';
|
||||
import { DEFAULT_APPEARANCE, type AppearanceConfig } from '@trek/shared';
|
||||
import type { Settings } from '../../../../src/types';
|
||||
import MSettingsAppearance from '../../../../src/mobile/screens/settings/MSettingsAppearance';
|
||||
|
||||
type UpdateSettingMock = ReturnType<typeof vi.fn>;
|
||||
|
||||
function seedAppearance(
|
||||
appearance: Partial<AppearanceConfig> | undefined,
|
||||
over: Partial<Settings> = {},
|
||||
): UpdateSettingMock {
|
||||
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useSettingsStore, {
|
||||
settings: buildSettings({
|
||||
language: 'en',
|
||||
dark_mode: 'light',
|
||||
appearance: appearance ? { ...DEFAULT_APPEARANCE, ...appearance } : undefined,
|
||||
...over,
|
||||
}),
|
||||
updateSetting,
|
||||
});
|
||||
return updateSetting;
|
||||
}
|
||||
|
||||
function renderAppearance() {
|
||||
return render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<MSettingsAppearance />
|
||||
</>,
|
||||
);
|
||||
}
|
||||
|
||||
/** Persisted appearance blobs, in call order. */
|
||||
function appearanceCalls(mock: UpdateSettingMock): AppearanceConfig[] {
|
||||
return mock.mock.calls.filter((c) => c[0] === 'appearance').map((c) => c[1] as AppearanceConfig);
|
||||
}
|
||||
|
||||
/** The most recently persisted appearance blob (throws until one exists). */
|
||||
function lastAppearance(mock: UpdateSettingMock): AppearanceConfig {
|
||||
const calls = appearanceCalls(mock);
|
||||
if (calls.length === 0) throw new Error('no appearance persisted yet');
|
||||
return calls[calls.length - 1];
|
||||
}
|
||||
|
||||
describe('MSettingsAppearance', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
usePluginStore.setState({ plugins: [], loaded: true });
|
||||
seedAppearance(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.documentElement.removeAttribute('data-scheme');
|
||||
document.documentElement.removeAttribute('data-density');
|
||||
document.documentElement.removeAttribute('data-no-transparency');
|
||||
document.documentElement.removeAttribute('data-reduce-motion');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETAPP-001: renders the four cards and the reset action', () => {
|
||||
renderAppearance();
|
||||
|
||||
expect(screen.getByText('Theme')).toBeInTheDocument();
|
||||
expect(screen.getByText('Mobile', { selector: 'span' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Readability')).toBeInTheDocument();
|
||||
expect(screen.getByText('Experimental')).toBeInTheDocument();
|
||||
expect(screen.getByText('Dashboard widgets')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Reset to defaults/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETAPP-002: the color mode segment reflects the stored dark_mode', () => {
|
||||
seedAppearance(undefined, { dark_mode: 'dark' });
|
||||
renderAppearance();
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Dark' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: 'Light' })).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETAPP-003: a legacy boolean dark_mode still resolves to the dark segment', () => {
|
||||
seedAppearance(undefined, { dark_mode: true });
|
||||
renderAppearance();
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Dark' })).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETAPP-004: picking a color mode persists it right away', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSetting = seedAppearance(undefined);
|
||||
renderAppearance();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Auto' }));
|
||||
expect(updateSetting).toHaveBeenCalledWith('dark_mode', 'auto');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETAPP-005: a failing color-mode save shows the error toast', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedStore(useSettingsStore, {
|
||||
settings: buildSettings({ language: 'en', dark_mode: 'light' }),
|
||||
updateSetting: vi.fn().mockRejectedValue(new Error('Server down')),
|
||||
});
|
||||
renderAppearance();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Dark' }));
|
||||
await screen.findByText('Server down');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETAPP-006: choosing a scheme applies it to the DOM and persists it debounced', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSetting = seedAppearance(undefined);
|
||||
renderAppearance();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Teal' }));
|
||||
|
||||
expect(document.documentElement.getAttribute('data-scheme')).toBe('teal');
|
||||
await waitFor(() => expect(appearanceCalls(updateSetting)[0]).toMatchObject({ schemeId: 'teal' }));
|
||||
});
|
||||
|
||||
it('FE-MOB-SETAPP-007: rapid edits collapse into a single persisted blob', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSetting = seedAppearance(undefined);
|
||||
renderAppearance();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Indigo' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Rose' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Violet' }));
|
||||
|
||||
await waitFor(() => expect(appearanceCalls(updateSetting)).toHaveLength(1));
|
||||
expect(appearanceCalls(updateSetting)[0].schemeId).toBe('violet');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETAPP-008: a rejected appearance save surfaces the error toast', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedStore(useSettingsStore, {
|
||||
settings: buildSettings({ language: 'en', dark_mode: 'light' }),
|
||||
updateSetting: vi.fn().mockRejectedValue(new Error('Blob too large')),
|
||||
});
|
||||
renderAppearance();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Amber' }));
|
||||
await screen.findByText('Blob too large');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETAPP-009: the custom scheme reveals the accent presets and the contrast badge', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAppearance();
|
||||
|
||||
expect(screen.queryByText('Custom accent')).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Custom' }));
|
||||
|
||||
expect(screen.getByText('Custom accent')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '#4f46e5' })).toBeInTheDocument();
|
||||
expect(screen.getByText(/Good contrast \(\d\.\d:1\)/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETAPP-010: a preset swatch sets both accents and can drop the contrast rating', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSetting = seedAppearance({ schemeId: 'custom', accent: { light: '#4f46e5', dark: '#6366f1' } });
|
||||
renderAppearance();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '#d97706' }));
|
||||
|
||||
expect(screen.getByText(/Low contrast \(\d\.\d:1\)/)).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(appearanceCalls(updateSetting)[0].accent).toEqual({ light: '#d97706', dark: '#d97706' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-MOB-SETAPP-011: the two color inputs edit their own mode only', async () => {
|
||||
const updateSetting = seedAppearance({ schemeId: 'custom', accent: { light: '#4f46e5', dark: '#6366f1' } });
|
||||
renderAppearance();
|
||||
|
||||
const [lightInput, darkInput] = screen.getAllByDisplayValue(/#/) as HTMLInputElement[];
|
||||
fireEvent.change(lightInput, { target: { value: '#123456' } });
|
||||
expect(darkInput.value).toBe('#6366f1');
|
||||
|
||||
fireEvent.change(screen.getAllByDisplayValue(/#/)[1], { target: { value: '#abcdef' } });
|
||||
await waitFor(() =>
|
||||
expect(lastAppearance(updateSetting).accent).toEqual({ light: '#123456', dark: '#abcdef' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-MOB-SETAPP-012: a shorthand accent hex is expanded for the contrast rating', () => {
|
||||
seedAppearance({ schemeId: 'custom', accent: { light: '#abc', dark: '#abc' } });
|
||||
renderAppearance();
|
||||
|
||||
expect(screen.getByText(/contrast \(\d\.\d:1\)/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETAPP-013: dark mode rates the dark accent instead of the light one', () => {
|
||||
seedAppearance({ schemeId: 'custom', accent: { light: '#111111', dark: '#fde68a' } }, { dark_mode: 'dark' });
|
||||
renderAppearance();
|
||||
|
||||
// Near-white on white is unreadable — the dark accent is the one under test.
|
||||
expect(screen.getByText(/Low contrast/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETAPP-014: auto mode follows the OS preference for the accent rating', () => {
|
||||
vi.spyOn(window, 'matchMedia').mockReturnValue({ matches: true } as MediaQueryList);
|
||||
seedAppearance({ schemeId: 'custom', accent: { light: '#111111', dark: '#fde68a' } }, { dark_mode: 'auto' });
|
||||
renderAppearance();
|
||||
|
||||
expect(screen.getByText(/Low contrast/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETAPP-015: the readability switches write their DOM markers', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSetting = seedAppearance(undefined);
|
||||
renderAppearance();
|
||||
|
||||
await user.click(screen.getByRole('switch', { name: 'Transparency' }));
|
||||
expect(document.documentElement.hasAttribute('data-no-transparency')).toBe(true);
|
||||
|
||||
await user.click(screen.getByRole('switch', { name: 'Reduce motion' }));
|
||||
expect(document.documentElement.hasAttribute('data-reduce-motion')).toBe(true);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(lastAppearance(updateSetting)).toMatchObject({ transparency: false, reduceMotion: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-MOB-SETAPP-016: switching to compact density sets the density attribute', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSetting = seedAppearance(undefined);
|
||||
renderAppearance();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Compact' }));
|
||||
|
||||
expect(document.documentElement.getAttribute('data-density')).toBe('compact');
|
||||
await waitFor(() => expect(appearanceCalls(updateSetting)[0]).toMatchObject({ density: 'compact' }));
|
||||
});
|
||||
|
||||
it('FE-MOB-SETAPP-017: the global text slider shows its percentage and persists fontScale', async () => {
|
||||
const updateSetting = seedAppearance(undefined);
|
||||
renderAppearance();
|
||||
|
||||
const sliders = screen.getAllByRole('slider') as HTMLInputElement[];
|
||||
expect(sliders).toHaveLength(5);
|
||||
fireEvent.change(sliders[0], { target: { value: '1.2' } });
|
||||
|
||||
expect(screen.getByText('120%')).toBeInTheDocument();
|
||||
await waitFor(() => expect(appearanceCalls(updateSetting)[0]).toMatchObject({ fontScale: 1.2 }));
|
||||
});
|
||||
|
||||
it('FE-MOB-SETAPP-018: each per-tier slider only moves its own scale', async () => {
|
||||
const updateSetting = seedAppearance(undefined);
|
||||
renderAppearance();
|
||||
|
||||
const sliders = screen.getAllByRole('slider') as HTMLInputElement[];
|
||||
fireEvent.change(sliders[1], { target: { value: '1.4' } });
|
||||
fireEvent.change(sliders[2], { target: { value: '1.3' } });
|
||||
fireEvent.change(sliders[3], { target: { value: '0.9' } });
|
||||
fireEvent.change(sliders[4], { target: { value: '0.8' } });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(lastAppearance(updateSetting).typeScale).toEqual({
|
||||
title: 1.4,
|
||||
subtitle: 1.3,
|
||||
body: 0.9,
|
||||
caption: 0.8,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-MOB-SETAPP-019: desktop and mobile widget toggles are independent', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSetting = seedAppearance(undefined);
|
||||
renderAppearance();
|
||||
|
||||
const currencySwitches = screen.getAllByRole('switch', { name: 'Currency' });
|
||||
expect(currencySwitches).toHaveLength(2);
|
||||
|
||||
await user.click(currencySwitches[0]);
|
||||
await waitFor(() => expect(lastAppearance(updateSetting).dashboard.desktop.currency).toBe(false));
|
||||
expect(lastAppearance(updateSetting).dashboard.mobile.currency).toBe(true);
|
||||
|
||||
await user.click(screen.getAllByRole('switch', { name: 'Currency' })[1]);
|
||||
await waitFor(() => expect(lastAppearance(updateSetting).dashboard.mobile.currency).toBe(false));
|
||||
});
|
||||
|
||||
it('FE-MOB-SETAPP-020: turning the sidebar master off disables its child toggles', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAppearance();
|
||||
|
||||
const master = screen.getByRole('switch', { name: 'Right sidebar' });
|
||||
expect(screen.getAllByRole('switch', { name: 'Timezones' })[0]).toBeEnabled();
|
||||
|
||||
await user.click(master);
|
||||
expect(screen.getAllByRole('switch', { name: 'Timezones' })[0]).toBeDisabled();
|
||||
// The mobile twin stays usable.
|
||||
expect(screen.getAllByRole('switch', { name: 'Timezones' })[1]).toBeEnabled();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETAPP-021: the desktop-only widgets render outside the sidebar group', () => {
|
||||
renderAppearance();
|
||||
|
||||
expect(screen.getByRole('switch', { name: 'Atlas / countries' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('switch', { name: 'Distance flown' })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('switch', { name: 'Trips total' })).toHaveLength(2);
|
||||
// "Below the hero" exists once per device, "Bottom of page" only on mobile.
|
||||
expect(screen.getAllByText('Below the hero')).toHaveLength(2);
|
||||
expect(screen.getByText('Bottom of page')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETAPP-022: the mobile card carries the nav customizer and the dashboard order', () => {
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'vacay', name: 'Vacay', type: 'global', icon: 'calendar', enabled: true }],
|
||||
loaded: true,
|
||||
});
|
||||
renderAppearance();
|
||||
|
||||
expect(screen.getByText('Bottom navbar')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pinned')).toBeInTheDocument();
|
||||
expect(screen.getByText('Dashboard order')).toBeInTheDocument();
|
||||
expect(screen.getByText('Trips')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETAPP-023: reordering the mobile dashboard persists the new token order', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSetting = seedAppearance(undefined);
|
||||
renderAppearance();
|
||||
|
||||
await user.click(screen.getAllByLabelText('Move down')[0]);
|
||||
await waitFor(() =>
|
||||
expect(lastAppearance(updateSetting).dashboard.mobileOrder).toEqual([
|
||||
'currency',
|
||||
'trips',
|
||||
'collections',
|
||||
'timezones',
|
||||
'upcomingReservations',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-MOB-SETAPP-024: editing the bottom navbar persists the new split', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedStore(useAddonStore, {
|
||||
addons: [
|
||||
{ id: 'vacay', name: 'Vacay', type: 'global', icon: 'calendar', enabled: true },
|
||||
{ id: 'atlas', name: 'Atlas', type: 'global', icon: 'map', enabled: true },
|
||||
],
|
||||
loaded: true,
|
||||
});
|
||||
const updateSetting = seedAppearance(undefined);
|
||||
renderAppearance();
|
||||
|
||||
await user.click(screen.getAllByLabelText('Move under “More”')[0]);
|
||||
await waitFor(() =>
|
||||
expect(lastAppearance(updateSetting).mobileNav).toEqual({ bar: ['atlas'], more: ['vacay'] }),
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-MOB-SETAPP-025: reset restores the defaults and clears the DOM markers', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSetting = seedAppearance({ schemeId: 'rose', density: 'compact', reduceMotion: true, fontScale: 1.3 });
|
||||
renderAppearance();
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Compact' })).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Reset to defaults/ }));
|
||||
|
||||
expect(document.documentElement.hasAttribute('data-scheme')).toBe(false);
|
||||
expect(document.documentElement.hasAttribute('data-density')).toBe(false);
|
||||
expect(document.documentElement.hasAttribute('data-reduce-motion')).toBe(false);
|
||||
expect(screen.getByRole('button', { name: 'Comfortable' })).toHaveAttribute('aria-pressed', 'true');
|
||||
await waitFor(() => expect(appearanceCalls(updateSetting)[0]).toEqual(DEFAULT_APPEARANCE));
|
||||
});
|
||||
|
||||
it('FE-MOB-SETAPP-026: a settings change from elsewhere re-syncs the local config', async () => {
|
||||
const { rerender } = renderAppearance();
|
||||
expect(screen.getByRole('button', { name: 'Comfortable' })).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
seedAppearance({ density: 'compact' });
|
||||
rerender(<MSettingsAppearance />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: 'Compact' })).toHaveAttribute('aria-pressed', 'true'),
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-MOB-SETAPP-027: unmounting before the debounce fires cancels the pending save', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSetting = seedAppearance(undefined);
|
||||
const { unmount } = renderAppearance();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Indigo' }));
|
||||
unmount();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
expect(appearanceCalls(updateSetting)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { render, screen } from '../../../helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store';
|
||||
import { buildSettings } from '../../../helpers/factories';
|
||||
import { useSettingsStore } from '../../../../src/store/settingsStore';
|
||||
import { ToastContainer } from '../../../../src/components/shared/Toast';
|
||||
import MSettingsGeneral from '../../../../src/mobile/screens/settings/MSettingsGeneral';
|
||||
|
||||
describe('MSettingsGeneral', () => {
|
||||
@@ -51,4 +52,108 @@ describe('MSettingsGeneral', () => {
|
||||
await user.click(await screen.findByRole('button', { name: 'Deutsch' }));
|
||||
expect(updateSetting).toHaveBeenCalledWith('language', 'de');
|
||||
});
|
||||
|
||||
it('FE-MOB-SET-004: a chosen display currency is shown with its symbol', () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ language: 'en', default_currency: 'EUR' }) });
|
||||
render(<MSettingsGeneral />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'EUR — €' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SET-005: the currency sheet saves a pick and can fall back to the trip currency', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useSettingsStore, {
|
||||
settings: buildSettings({ language: 'en', default_currency: '' }),
|
||||
updateSetting,
|
||||
});
|
||||
render(<MSettingsGeneral />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Trip currency' }));
|
||||
await user.click(await screen.findByRole('button', { name: 'CHF — CHF' }));
|
||||
expect(updateSetting).toHaveBeenCalledWith('default_currency', 'CHF');
|
||||
});
|
||||
|
||||
it('FE-MOB-SET-006: distance and time-format segments persist their preference', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useSettingsStore, {
|
||||
settings: buildSettings({ language: 'en', distance_unit: 'metric', time_format: '24h' }),
|
||||
updateSetting,
|
||||
});
|
||||
render(<MSettingsGeneral />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'km Metric' })).toHaveAttribute('aria-pressed', 'true');
|
||||
await user.click(screen.getByRole('button', { name: 'mi Imperial' }));
|
||||
expect(updateSetting).toHaveBeenCalledWith('distance_unit', 'imperial');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '12h' }));
|
||||
expect(updateSetting).toHaveBeenCalledWith('time_format', '12h');
|
||||
});
|
||||
|
||||
it('FE-MOB-SET-007: unset unit preferences fall back to the metric/24h defaults', () => {
|
||||
seedStore(useSettingsStore, {
|
||||
settings: buildSettings({ language: 'en', temperature_unit: undefined, distance_unit: undefined, time_format: undefined }),
|
||||
});
|
||||
render(<MSettingsGeneral />);
|
||||
|
||||
expect(screen.getByRole('button', { name: '°C Celsius' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: 'km Metric' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: '24h' })).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('FE-MOB-SET-008: the travel toggles reflect their opt-in / opt-out defaults', () => {
|
||||
render(<MSettingsGeneral />);
|
||||
|
||||
expect(screen.getByRole('switch', { name: 'Booking route labels' })).toHaveAttribute('aria-checked', 'false');
|
||||
expect(screen.getByRole('switch', { name: 'Explore places on the map' })).toHaveAttribute('aria-checked', 'true');
|
||||
expect(screen.getByRole('switch', { name: 'Blur Booking Codes' })).toHaveAttribute('aria-checked', 'false');
|
||||
expect(screen.getByRole('switch', { name: 'Optimize route from accommodation' })).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
it('FE-MOB-SET-009: flipping a travel toggle persists the new boolean', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useSettingsStore, {
|
||||
settings: buildSettings({ language: 'en', map_booking_labels: false, map_poi_pill_enabled: true }),
|
||||
updateSetting,
|
||||
});
|
||||
render(<MSettingsGeneral />);
|
||||
|
||||
await user.click(screen.getByRole('switch', { name: 'Booking route labels' }));
|
||||
expect(updateSetting).toHaveBeenCalledWith('map_booking_labels', true);
|
||||
|
||||
await user.click(screen.getByRole('switch', { name: 'Explore places on the map' }));
|
||||
expect(updateSetting).toHaveBeenCalledWith('map_poi_pill_enabled', false);
|
||||
|
||||
await user.click(screen.getByRole('switch', { name: 'Blur Booking Codes' }));
|
||||
expect(updateSetting).toHaveBeenCalledWith('blur_booking_codes', true);
|
||||
|
||||
await user.click(screen.getByRole('switch', { name: 'Optimize route from accommodation' }));
|
||||
expect(updateSetting).toHaveBeenCalledWith('optimize_from_accommodation', false);
|
||||
});
|
||||
|
||||
it('FE-MOB-SET-010: a rejected save shows the server message as a toast', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedStore(useSettingsStore, {
|
||||
settings: buildSettings({ language: 'en' }),
|
||||
updateSetting: vi.fn().mockRejectedValue(new Error('Error saving setting')),
|
||||
});
|
||||
render(<><ToastContainer /><MSettingsGeneral /></>);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '°F Fahrenheit' }));
|
||||
await screen.findByText('Error saving setting');
|
||||
});
|
||||
|
||||
it('FE-MOB-SET-011: a non-Error rejection falls back to the generic message', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedStore(useSettingsStore, {
|
||||
settings: buildSettings({ language: 'en' }),
|
||||
updateSetting: vi.fn().mockRejectedValue('boom'),
|
||||
});
|
||||
render(<><ToastContainer /><MSettingsGeneral /></>);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '°F Fahrenheit' }));
|
||||
await screen.findByText('Error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// FE-MOB-SETINT-001 onwards
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '../../../helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store';
|
||||
import { useAddonStore } from '../../../../src/store/addonStore';
|
||||
import MSettingsIntegrations from '../../../../src/mobile/screens/settings/MSettingsIntegrations';
|
||||
|
||||
// The four sections are covered by their own suites — stub them so this file
|
||||
// only exercises the addon gating and the initial loadAddons call.
|
||||
vi.mock('../../../../src/mobile/screens/settings/MPhotoProvidersSection', () => ({
|
||||
default: () => <div data-testid="photo-providers" />,
|
||||
}));
|
||||
vi.mock('../../../../src/mobile/screens/settings/MAirTrailConnectionSection', () => ({
|
||||
default: () => <div data-testid="airtrail" />,
|
||||
}));
|
||||
vi.mock('../../../../src/mobile/screens/settings/MLlmConnectionSection', () => ({
|
||||
default: () => <div data-testid="llm" />,
|
||||
}));
|
||||
vi.mock('../../../../src/mobile/screens/settings/MSettingsMcp', () => ({
|
||||
default: () => <div data-testid="mcp" />,
|
||||
}));
|
||||
|
||||
function seedAddons(ids: string[], loadAddons = vi.fn().mockResolvedValue(undefined)) {
|
||||
seedStore(useAddonStore, {
|
||||
addons: ids.map((id) => ({ id, name: id, type: 'integration', icon: '', enabled: true })),
|
||||
loaded: true,
|
||||
loadAddons,
|
||||
});
|
||||
return loadAddons;
|
||||
}
|
||||
|
||||
describe('MSettingsIntegrations', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETINT-001: only the photo providers render when no integration addon is on', () => {
|
||||
seedAddons([]);
|
||||
render(<MSettingsIntegrations />);
|
||||
|
||||
expect(screen.getByTestId('photo-providers')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('airtrail')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('llm')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('mcp')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETINT-002: the airtrail addon reveals the AirTrail connection section', () => {
|
||||
seedAddons(['airtrail']);
|
||||
render(<MSettingsIntegrations />);
|
||||
|
||||
expect(screen.getByTestId('airtrail')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('llm')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETINT-003: the llm_parsing addon reveals the AI connection section', () => {
|
||||
seedAddons(['llm_parsing']);
|
||||
render(<MSettingsIntegrations />);
|
||||
|
||||
expect(screen.getByTestId('llm')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('airtrail')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETINT-004: the mcp addon reveals the MCP configuration', () => {
|
||||
seedAddons(['mcp']);
|
||||
render(<MSettingsIntegrations />);
|
||||
|
||||
expect(screen.getByTestId('mcp')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETINT-005: all three sections render together when every addon is on', () => {
|
||||
seedAddons(['airtrail', 'llm_parsing', 'mcp']);
|
||||
render(<MSettingsIntegrations />);
|
||||
|
||||
expect(screen.getByTestId('airtrail')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('llm')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('mcp')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETINT-006: a disabled addon row does not unlock its section', () => {
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'mcp', name: 'MCP', type: 'integration', icon: '', enabled: false }],
|
||||
loaded: true,
|
||||
loadAddons: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
render(<MSettingsIntegrations />);
|
||||
|
||||
expect(screen.queryByTestId('mcp')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETINT-007: the addon list is refreshed once on mount', () => {
|
||||
const loadAddons = seedAddons([]);
|
||||
const { rerender } = render(<MSettingsIntegrations />);
|
||||
rerender(<MSettingsIntegrations />);
|
||||
|
||||
expect(loadAddons).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
// FE-MOB-SETMAP-001 onwards
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render, screen, waitFor } from '../../../helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store';
|
||||
import { buildSettings } from '../../../helpers/factories';
|
||||
import { useSettingsStore } from '../../../../src/store/settingsStore';
|
||||
import { ToastContainer } from '../../../../src/components/shared/Toast';
|
||||
import type { Settings } from '../../../../src/types';
|
||||
import MSettingsMap from '../../../../src/mobile/screens/settings/MSettingsMap';
|
||||
|
||||
// Leaflet and the GL preview need a real canvas — stub both and expose the
|
||||
// props the screen feeds them so the preview wiring stays assertable.
|
||||
vi.mock('../../../../src/components/Map/MapView', () => ({
|
||||
MapView: ({ tileUrl }: { tileUrl: string }) => <div data-testid="leaflet-preview" data-tile={tileUrl} />,
|
||||
}));
|
||||
vi.mock('../../../../src/components/Settings/MapboxPreview', () => ({
|
||||
default: ({ provider, token, style, enable3d, quality }: {
|
||||
provider: string; token: string; style: string; enable3d: boolean; quality: boolean;
|
||||
}) => (
|
||||
<div
|
||||
data-testid="gl-preview"
|
||||
data-provider={provider}
|
||||
data-token={token}
|
||||
data-style={style}
|
||||
data-3d={String(enable3d)}
|
||||
data-quality={String(quality)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
const OSM_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||
|
||||
function seedMap(over: Partial<Settings> = {}, updateSettings = vi.fn().mockResolvedValue(undefined)) {
|
||||
seedStore(useSettingsStore, {
|
||||
settings: buildSettings({ language: 'en', map_tile_url: '', ...over }),
|
||||
updateSettings,
|
||||
});
|
||||
return updateSettings;
|
||||
}
|
||||
|
||||
function renderMap() {
|
||||
return render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<MSettingsMap />
|
||||
</>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('MSettingsMap', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedMap();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMAP-001: offers the three providers with Leaflet active by default', () => {
|
||||
renderMap();
|
||||
|
||||
expect(screen.getByText('Leaflet')).toBeInTheDocument();
|
||||
expect(screen.getByText('Mapbox GL')).toBeInTheDocument();
|
||||
expect(screen.getByText('MapLibre GL')).toBeInTheDocument();
|
||||
expect(screen.getByText('Classic 2D, any raster tiles')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('leaflet-preview')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMAP-002: the Leaflet branch shows the tile template row and the raw URL input', () => {
|
||||
seedMap({ map_tile_url: OSM_URL });
|
||||
renderMap();
|
||||
|
||||
expect(screen.getByText('OpenStreetMap')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue(OSM_URL)).toBeInTheDocument();
|
||||
expect(screen.getByTestId('leaflet-preview')).toHaveAttribute('data-tile', OSM_URL);
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMAP-003: an unknown tile URL falls back to the select placeholder', () => {
|
||||
seedMap({ map_tile_url: 'https://custom.tiles/{z}/{x}/{y}.png' });
|
||||
renderMap();
|
||||
|
||||
expect(screen.getByText('Select template...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMAP-004: the template sheet writes the chosen preset into the URL field', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMap();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Select template/ }));
|
||||
await user.click(await screen.findByRole('button', { name: 'CartoDB Dark' }));
|
||||
|
||||
expect(screen.getByDisplayValue('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMAP-005: typing a tile URL feeds straight into the Leaflet preview', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMap();
|
||||
|
||||
await user.type(screen.getByPlaceholderText(OSM_URL), 'https://tiles.test/a.png');
|
||||
expect(screen.getByTestId('leaflet-preview')).toHaveAttribute('data-tile', 'https://tiles.test/a.png');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMAP-006: switching to Mapbox GL swaps in the token, style and 3D controls', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMap();
|
||||
|
||||
await user.click(screen.getByText('Mapbox GL'));
|
||||
|
||||
expect(screen.getByPlaceholderText('pk.eyJ1Ijoi...')).toBeInTheDocument();
|
||||
expect(screen.getByText('Mapbox Standard')).toBeInTheDocument();
|
||||
expect(screen.getByRole('switch', { name: '3D Buildings & Terrain' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('switch', { name: 'High Quality Mode' })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('gl-preview')).toHaveAttribute('data-provider', 'mapbox-gl');
|
||||
expect(screen.queryByTestId('leaflet-preview')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMAP-007: MapLibre drops the token field and normalises to an OpenFreeMap style', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMap();
|
||||
|
||||
await user.click(screen.getByText('MapLibre GL'));
|
||||
|
||||
expect(screen.queryByPlaceholderText('pk.eyJ1Ijoi...')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('switch', { name: '3D Buildings & Terrain' })).not.toBeInTheDocument();
|
||||
expect(screen.getByText('OpenFreeMap Liberty')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('gl-preview')).toHaveAttribute('data-style', 'https://tiles.openfreemap.org/styles/liberty');
|
||||
expect(screen.getByTestId('gl-preview')).toHaveAttribute('data-3d', 'false');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMAP-008: a custom mapbox:// style shows the "pick a style" placeholder', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedMap({ map_provider: 'mapbox-gl', mapbox_style: 'mapbox://styles/me/custom' });
|
||||
renderMap();
|
||||
|
||||
expect(screen.getByText('Select a Mapbox style')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Select a Mapbox style/ }));
|
||||
await user.click(await screen.findByRole('button', { name: /Satellite Streets/ }));
|
||||
expect(screen.getByDisplayValue('mapbox://styles/mapbox/satellite-streets-v12')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMAP-009: an unknown MapLibre style shows the OpenFreeMap placeholder', () => {
|
||||
seedMap({ map_provider: 'maplibre-gl', maplibre_style: 'https://tiles.openfreemap.org/styles/mine' });
|
||||
renderMap();
|
||||
|
||||
expect(screen.getByText('Select an OpenFreeMap style')).toBeInTheDocument();
|
||||
expect(screen.getByText('Preset or OpenFreeMap style URL. OpenFreeMap styles work without a token.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMAP-010: a stored mapbox provider hydrates token, style and both switches', () => {
|
||||
seedMap({
|
||||
map_provider: 'mapbox-gl',
|
||||
mapbox_access_token: 'pk.token',
|
||||
mapbox_style: 'mapbox://styles/mapbox/dark-v11',
|
||||
mapbox_3d_enabled: false,
|
||||
mapbox_quality_mode: true,
|
||||
});
|
||||
renderMap();
|
||||
|
||||
expect(screen.getByDisplayValue('pk.token')).toBeInTheDocument();
|
||||
expect(screen.getByRole('switch', { name: '3D Buildings & Terrain' })).toHaveAttribute('aria-checked', 'false');
|
||||
expect(screen.getByRole('switch', { name: 'High Quality Mode' })).toHaveAttribute('aria-checked', 'true');
|
||||
const preview = screen.getByTestId('gl-preview');
|
||||
expect(preview).toHaveAttribute('data-token', 'pk.token');
|
||||
expect(preview).toHaveAttribute('data-3d', 'false');
|
||||
expect(preview).toHaveAttribute('data-quality', 'true');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMAP-011: the 3D and quality switches drive the preview', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedMap({ map_provider: 'mapbox-gl', mapbox_access_token: 'pk.token' });
|
||||
renderMap();
|
||||
|
||||
await user.click(screen.getByRole('switch', { name: '3D Buildings & Terrain' }));
|
||||
await user.click(screen.getByRole('switch', { name: 'High Quality Mode' }));
|
||||
|
||||
const preview = screen.getByTestId('gl-preview');
|
||||
expect(preview).toHaveAttribute('data-3d', 'false');
|
||||
expect(preview).toHaveAttribute('data-quality', 'true');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMAP-012: saving Leaflet persists the tile URL and keeps the mapbox style slot', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSettings = seedMap({ map_tile_url: OSM_URL, mapbox_style: 'mapbox://styles/mapbox/dark-v11' });
|
||||
renderMap();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save Map' }));
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledWith({
|
||||
map_provider: 'leaflet',
|
||||
map_tile_url: OSM_URL,
|
||||
mapbox_access_token: '',
|
||||
mapbox_style: 'mapbox://styles/mapbox/dark-v11',
|
||||
mapbox_3d_enabled: true,
|
||||
mapbox_quality_mode: false,
|
||||
});
|
||||
await screen.findByText('Map settings saved');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMAP-013: saving MapLibre writes the maplibre slot, not the mapbox one', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSettings = seedMap({ map_provider: 'maplibre-gl' });
|
||||
renderMap();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save Map' }));
|
||||
|
||||
const saved = updateSettings.mock.calls[0][0] as Partial<Settings>;
|
||||
expect(saved.maplibre_style).toBe('https://tiles.openfreemap.org/styles/liberty');
|
||||
expect(saved).not.toHaveProperty('mapbox_style');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMAP-014: a non-OpenFreeMap style typed for MapLibre is normalised on save', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSettings = seedMap({ map_provider: 'maplibre-gl' });
|
||||
renderMap();
|
||||
|
||||
const styleInput = screen.getByPlaceholderText('https://tiles.openfreemap.org/styles/liberty');
|
||||
await user.clear(styleInput);
|
||||
await user.type(styleInput, 'mapbox://styles/mapbox/streets-v12');
|
||||
await user.click(screen.getByRole('button', { name: 'Save Map' }));
|
||||
|
||||
const saved = updateSettings.mock.calls[0][0] as Partial<Settings>;
|
||||
expect(saved.maplibre_style).toBe('https://tiles.openfreemap.org/styles/liberty');
|
||||
expect(screen.getByDisplayValue('https://tiles.openfreemap.org/styles/liberty')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMAP-015: the save button is disabled while the request is in flight', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedMap({}, vi.fn().mockReturnValue(new Promise(() => {})));
|
||||
renderMap();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save Map' }));
|
||||
expect(screen.getByRole('button', { name: 'Save Map' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMAP-016: a failed save surfaces the error message', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedMap({}, vi.fn().mockRejectedValue(new Error('Storage full')));
|
||||
renderMap();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save Map' }));
|
||||
await screen.findByText('Storage full');
|
||||
expect(screen.getByRole('button', { name: 'Save Map' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMAP-017: a settings change from elsewhere re-syncs the local form', async () => {
|
||||
renderMap();
|
||||
|
||||
seedMap({ map_tile_url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByDisplayValue('https://tile.openstreetmap.de/{z}/{x}/{y}.png')).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByText('OpenStreetMap DE')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMAP-018: a typed Mapbox token reaches the preview and the save payload', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSettings = seedMap({ map_provider: 'mapbox-gl' });
|
||||
renderMap();
|
||||
|
||||
await user.type(screen.getByPlaceholderText('pk.eyJ1Ijoi...'), 'pk.new-token');
|
||||
expect(screen.getByTestId('gl-preview')).toHaveAttribute('data-token', 'pk.new-token');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save Map' }));
|
||||
expect(updateSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mapbox_access_token: 'pk.new-token', map_provider: 'mapbox-gl' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMAP-019: a mapbox style stored while MapLibre is active is not carried over', () => {
|
||||
seedMap({ map_provider: 'maplibre-gl', maplibre_style: 'mapbox://styles/mapbox/dark-v11' });
|
||||
renderMap();
|
||||
|
||||
expect(screen.getByTestId('gl-preview')).toHaveAttribute('data-style', 'https://tiles.openfreemap.org/styles/liberty');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,575 @@
|
||||
// FE-MOB-SETMCP-001 onwards
|
||||
import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { render, screen, fireEvent, waitFor } from '../../../helpers/render';
|
||||
import { server } from '../../../helpers/msw/server';
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store';
|
||||
import { buildSettings } from '../../../helpers/factories';
|
||||
import { useSettingsStore } from '../../../../src/store/settingsStore';
|
||||
import { ToastContainer } from '../../../../src/components/shared/Toast';
|
||||
import MSettingsMcp from '../../../../src/mobile/screens/settings/MSettingsMcp';
|
||||
|
||||
const OAUTH_CLIENT = {
|
||||
id: 'cl-1',
|
||||
name: 'Claude Web',
|
||||
client_id: 'clid-abc',
|
||||
redirect_uris: ['https://claude.ai/api/mcp/auth_callback'],
|
||||
allowed_scopes: ['trips:read', 'trips:write', 'places:read', 'places:write', 'budget:read', 'budget:write', 'packing:read'],
|
||||
allows_client_credentials: false,
|
||||
created_at: '2025-03-01T10:00:00.000Z',
|
||||
};
|
||||
|
||||
const SESSION = {
|
||||
id: 77,
|
||||
client_id: 'clid-abc',
|
||||
client_name: 'Claude Desktop',
|
||||
scopes: ['trips:read'],
|
||||
access_token_expires_at: '2025-12-31T00:00:00.000Z',
|
||||
refresh_token_expires_at: '2026-12-31T00:00:00.000Z',
|
||||
created_at: '2025-03-01T10:00:00.000Z',
|
||||
};
|
||||
|
||||
const TOKEN = {
|
||||
id: 5,
|
||||
name: 'Work laptop',
|
||||
token_prefix: 'trek_ab12',
|
||||
created_at: '2025-02-01T09:00:00.000Z',
|
||||
last_used_at: '2025-02-05T09:00:00.000Z',
|
||||
};
|
||||
|
||||
function seedApi(opts: { clients?: unknown[]; sessions?: unknown[]; tokens?: unknown[] } = {}) {
|
||||
server.use(
|
||||
http.get('/api/oauth/clients', () => HttpResponse.json({ clients: opts.clients ?? [] })),
|
||||
http.get('/api/oauth/sessions', () => HttpResponse.json({ sessions: opts.sessions ?? [] })),
|
||||
http.get('/api/auth/mcp-tokens', () => HttpResponse.json({ tokens: opts.tokens ?? [] })),
|
||||
);
|
||||
}
|
||||
|
||||
function renderScreen() {
|
||||
return render(<><ToastContainer /><MSettingsMcp /></>);
|
||||
}
|
||||
|
||||
/** Switch to the deprecated API-tokens tab. */
|
||||
async function openTokenTab(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(screen.getByRole('tab', { name: 'API Tokens' }));
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }) });
|
||||
seedApi();
|
||||
});
|
||||
|
||||
describe('MSettingsMcp', () => {
|
||||
it('FE-MOB-SETMCP-001: shows the MCP endpoint and defaults to the OAuth tab', async () => {
|
||||
renderScreen();
|
||||
|
||||
expect(screen.getByText('MCP Configuration')).toBeInTheDocument();
|
||||
expect(screen.getByText(`${window.location.origin}/mcp`)).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'OAuth 2.1 Clients' })).toHaveAttribute('aria-selected', 'true');
|
||||
await waitFor(() => expect(screen.getByText('No OAuth clients registered.')).toBeInTheDocument());
|
||||
expect(screen.queryByText('No tokens yet. Create one to connect MCP clients.')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMCP-002: copying the endpoint writes it to the clipboard and flips the icon', async () => {
|
||||
const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
|
||||
renderScreen();
|
||||
|
||||
const copyBtn = screen.getByRole('button', { name: 'Copy' });
|
||||
fireEvent.click(copyBtn);
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/mcp`);
|
||||
await waitFor(() => expect(copyBtn.querySelector('svg')).toHaveClass('text-[color:var(--m-st-confirmed)]'));
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMCP-003: the OAuth client config block expands and copies its JSON', async () => {
|
||||
const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
|
||||
renderScreen();
|
||||
|
||||
expect(document.querySelector('pre')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Client Configuration' }));
|
||||
|
||||
const pre = document.querySelector('pre');
|
||||
expect(pre?.textContent).toContain('mcpServers');
|
||||
expect(pre?.textContent).toContain('--static-oauth-client-info');
|
||||
|
||||
// [0] is the endpoint copy button, [1] the one above the JSON block
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Copy' })[1]);
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('--static-oauth-client-info'));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Copied!' })).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMCP-004: the API tokens tab shows the deprecation notice and the bearer config', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderScreen();
|
||||
await openTokenTab(user);
|
||||
|
||||
expect(screen.getByText(/API Tokens are deprecated/)).toBeInTheDocument();
|
||||
await screen.findByText('No tokens yet. Create one to connect MCP clients.');
|
||||
expect(screen.queryByText('No OAuth clients registered.')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Client Configuration' }));
|
||||
expect(document.querySelector('pre')?.textContent).toContain('Authorization: Bearer <your_token>');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMCP-005: an OAuth client renders with its id and a collapsed scope list', async () => {
|
||||
seedApi({ clients: [OAUTH_CLIENT] });
|
||||
renderScreen();
|
||||
|
||||
await screen.findByText('Claude Web');
|
||||
expect(screen.getByText(/clid-abc/)).toBeInTheDocument();
|
||||
// 7 scopes, 5 shown plus a "+2" expander
|
||||
expect(screen.queryByText('packing:read')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '+2' }));
|
||||
expect(screen.getByText('packing:read')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '−' }));
|
||||
expect(screen.queryByText('packing:read')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMCP-006: a client-credentials client is badged as machine', async () => {
|
||||
seedApi({ clients: [{ ...OAUTH_CLIENT, allows_client_credentials: true, allowed_scopes: ['trips:read'] }] });
|
||||
renderScreen();
|
||||
|
||||
await screen.findByText('Claude Web');
|
||||
expect(screen.getByText('machine')).toBeInTheDocument();
|
||||
// Below the 5-scope threshold — no expander
|
||||
expect(screen.queryByRole('button', { name: /^\+\d+$/ })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMCP-007: a preset fills the create form and registers the client with split URIs', async () => {
|
||||
const user = userEvent.setup();
|
||||
let body: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.post('/api/oauth/clients', async ({ request }) => {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({ client: { ...OAUTH_CLIENT, id: 'new', client_id: 'clid-new', client_secret: 'sec-new' } });
|
||||
}),
|
||||
);
|
||||
renderScreen();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'New Client' }));
|
||||
await screen.findByText('Register OAuth Client');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Claude.ai' }));
|
||||
expect(screen.getByPlaceholderText(/Claude Web, My MCP App/)).toHaveValue('Claude.ai');
|
||||
|
||||
const uris = screen.getByPlaceholderText(/your-app/);
|
||||
fireEvent.change(uris, { target: { value: 'https://a.example/cb\n \nhttps://b.example/cb' } });
|
||||
await user.click(screen.getByRole('button', { name: 'Register Client' }));
|
||||
|
||||
await screen.findByText('Client Registered');
|
||||
expect(body).toMatchObject({
|
||||
name: 'Claude.ai',
|
||||
redirect_uris: ['https://a.example/cb', 'https://b.example/cb'],
|
||||
});
|
||||
// trips:delete is filtered out of the Claude.ai preset
|
||||
expect((body as unknown as { allowed_scopes: string[] }).allowed_scopes).toContain('trips:read');
|
||||
expect((body as unknown as { allowed_scopes: string[] }).allowed_scopes).not.toContain('trips:delete');
|
||||
|
||||
expect(screen.getByText('clid-new')).toBeInTheDocument();
|
||||
expect(screen.getByText('sec-new')).toBeInTheDocument();
|
||||
// The freshly created client is appended to the list without its secret
|
||||
expect(screen.getAllByText('Claude Web').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMCP-008: the created-client sheet ignores Escape and closes on Done', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/oauth/clients', () =>
|
||||
HttpResponse.json({ client: { ...OAUTH_CLIENT, client_id: 'clid-x', client_secret: 'sec-x' } })),
|
||||
);
|
||||
renderScreen();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'New Client' }));
|
||||
await screen.findByText('Register OAuth Client');
|
||||
// Escape closes while the form is still editable
|
||||
await user.keyboard('{Escape}');
|
||||
await waitFor(() => expect(screen.queryByText('Register OAuth Client')).toBeNull());
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'New Client' }));
|
||||
await user.type(await screen.findByPlaceholderText(/Claude Web, My MCP App/), 'X');
|
||||
fireEvent.change(screen.getByPlaceholderText(/your-app/), { target: { value: 'http://localhost' } });
|
||||
await user.click(screen.getByRole('button', { name: 'Register Client' }));
|
||||
await screen.findByText('Client Registered');
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
expect(screen.getByText('Client Registered')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Done' }));
|
||||
await waitFor(() => expect(screen.queryByText('Client Registered')).toBeNull());
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMCP-009: the machine toggle drops the redirect URIs and posts client_credentials', async () => {
|
||||
const user = userEvent.setup();
|
||||
let body: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.post('/api/oauth/clients', async ({ request }) => {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({
|
||||
client: { ...OAUTH_CLIENT, client_id: 'clid-m', client_secret: 'sec-m', allows_client_credentials: true },
|
||||
});
|
||||
}),
|
||||
);
|
||||
renderScreen();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'New Client' }));
|
||||
await screen.findByText('Register OAuth Client');
|
||||
expect(screen.getByText('Redirect URIs')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('switch', { name: 'Machine client (no browser login)' }));
|
||||
expect(screen.queryByText('Redirect URIs')).toBeNull();
|
||||
|
||||
await user.type(screen.getByPlaceholderText(/Claude Web, My MCP App/), 'CI bot');
|
||||
await user.click(screen.getByRole('button', { name: 'Register Client' }));
|
||||
|
||||
await screen.findByText('Client Registered');
|
||||
expect(body).toMatchObject({ name: 'CI bot', redirect_uris: [], allows_client_credentials: true });
|
||||
expect(screen.getByText(/grant_type=client_credentials/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMCP-010: copies the new client id and secret from the created sheet', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/oauth/clients', () =>
|
||||
HttpResponse.json({ client: { ...OAUTH_CLIENT, client_id: 'clid-copy', client_secret: 'sec-copy' } })),
|
||||
);
|
||||
renderScreen();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'New Client' }));
|
||||
await user.type(await screen.findByPlaceholderText(/Claude Web, My MCP App/), 'C');
|
||||
fireEvent.change(screen.getByPlaceholderText(/your-app/), { target: { value: 'http://localhost' } });
|
||||
await user.click(screen.getByRole('button', { name: 'Register Client' }));
|
||||
await screen.findByText('Client Registered');
|
||||
|
||||
const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
|
||||
const copyButtons = screen.getAllByRole('button', { name: 'Copy' });
|
||||
fireEvent.click(copyButtons[copyButtons.length - 2]);
|
||||
fireEvent.click(copyButtons[copyButtons.length - 1]);
|
||||
|
||||
expect(writeText).toHaveBeenNthCalledWith(1, 'clid-copy');
|
||||
expect(writeText).toHaveBeenNthCalledWith(2, 'sec-copy');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMCP-011: a failed registration surfaces an error toast and keeps the form open', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(http.post('/api/oauth/clients', () => HttpResponse.json({ error: 'boom' }, { status: 500 })));
|
||||
renderScreen();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'New Client' }));
|
||||
await user.type(await screen.findByPlaceholderText(/Claude Web, My MCP App/), 'Broken');
|
||||
fireEvent.change(screen.getByPlaceholderText(/your-app/), { target: { value: 'http://localhost' } });
|
||||
await user.click(screen.getByRole('button', { name: 'Register Client' }));
|
||||
|
||||
await screen.findByText('Failed to register OAuth client');
|
||||
expect(screen.getByText('Register OAuth Client')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMCP-012: deleting a client removes it from the list and toasts', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedApi({ clients: [OAUTH_CLIENT] });
|
||||
server.use(http.delete('/api/oauth/clients/cl-1', () => HttpResponse.json({ success: true })));
|
||||
renderScreen();
|
||||
|
||||
await screen.findByText('Claude Web');
|
||||
await user.click(screen.getByRole('button', { name: 'Delete Client' }));
|
||||
await screen.findByText(/This client and all active sessions will be permanently removed/);
|
||||
|
||||
const confirms = screen.getAllByRole('button', { name: 'Delete Client' });
|
||||
await user.click(confirms[confirms.length - 1]);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Claude Web')).toBeNull());
|
||||
expect(screen.getByText('OAuth client deleted')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMCP-013: a failed delete keeps the client and toasts the error', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedApi({ clients: [OAUTH_CLIENT] });
|
||||
server.use(http.delete('/api/oauth/clients/cl-1', () => HttpResponse.json({}, { status: 500 })));
|
||||
renderScreen();
|
||||
|
||||
await screen.findByText('Claude Web');
|
||||
await user.click(screen.getByRole('button', { name: 'Delete Client' }));
|
||||
const confirms = await screen.findAllByRole('button', { name: 'Delete Client' });
|
||||
await user.click(confirms[confirms.length - 1]);
|
||||
|
||||
await screen.findByText('Failed to delete OAuth client');
|
||||
expect(screen.getByText('Claude Web')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMCP-014: rotating a secret shows the new one and copies it', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedApi({ clients: [OAUTH_CLIENT] });
|
||||
server.use(http.post('/api/oauth/clients/cl-1/rotate', () => HttpResponse.json({ client_secret: 'rotated-1' })));
|
||||
renderScreen();
|
||||
|
||||
await screen.findByText('Claude Web');
|
||||
await user.click(screen.getByRole('button', { name: 'Rotate Secret' }));
|
||||
await screen.findByText(/A new client secret will be generated/);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Rotate' }));
|
||||
await screen.findByText('New Secret Generated');
|
||||
expect(screen.getByText('rotated-1')).toBeInTheDocument();
|
||||
|
||||
const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
|
||||
const copyButtons = screen.getAllByRole('button', { name: 'Copy' });
|
||||
fireEvent.click(copyButtons[copyButtons.length - 1]);
|
||||
expect(writeText).toHaveBeenCalledWith('rotated-1');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Done' }));
|
||||
await waitFor(() => expect(screen.queryByText('New Secret Generated')).toBeNull());
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMCP-015: a failed rotation toasts and leaves the confirm open', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedApi({ clients: [OAUTH_CLIENT] });
|
||||
server.use(http.post('/api/oauth/clients/cl-1/rotate', () => HttpResponse.json({}, { status: 500 })));
|
||||
renderScreen();
|
||||
|
||||
await screen.findByText('Claude Web');
|
||||
await user.click(screen.getByRole('button', { name: 'Rotate Secret' }));
|
||||
await user.click(await screen.findByRole('button', { name: 'Rotate' }));
|
||||
|
||||
await screen.findByText('Failed to rotate client secret');
|
||||
expect(screen.queryByText('New Secret Generated')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMCP-016: an active session can be revoked', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedApi({ sessions: [SESSION] });
|
||||
server.use(http.delete('/api/oauth/sessions/77', () => HttpResponse.json({ success: true })));
|
||||
renderScreen();
|
||||
|
||||
await screen.findByText('Claude Desktop');
|
||||
expect(screen.getByText(/Scopes: trips:read/)).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Revoke' }));
|
||||
await screen.findByText('This will immediately revoke access for this OAuth session.');
|
||||
const revokes = screen.getAllByRole('button', { name: 'Revoke' });
|
||||
await user.click(revokes[revokes.length - 1]);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Claude Desktop')).toBeNull());
|
||||
expect(screen.getByText('Session revoked')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMCP-017: a failed revoke keeps the session and toasts', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedApi({ sessions: [SESSION] });
|
||||
server.use(http.delete('/api/oauth/sessions/77', () => HttpResponse.json({}, { status: 500 })));
|
||||
renderScreen();
|
||||
|
||||
await screen.findByText('Claude Desktop');
|
||||
await user.click(screen.getByRole('button', { name: 'Revoke' }));
|
||||
const revokes = await screen.findAllByRole('button', { name: 'Revoke' });
|
||||
await user.click(revokes[revokes.length - 1]);
|
||||
|
||||
await screen.findByText('Failed to revoke session');
|
||||
expect(screen.getByText('Claude Desktop')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMCP-018: existing API tokens render with their prefix and usage dates', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedApi({ tokens: [TOKEN, { ...TOKEN, id: 6, name: 'Phone', last_used_at: null }] });
|
||||
renderScreen();
|
||||
await openTokenTab(user);
|
||||
|
||||
expect(await screen.findByText('Work laptop')).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/trek_ab12\.\.\./)).toHaveLength(2);
|
||||
// only the first token was ever used
|
||||
expect(screen.getByText(/· Used/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Phone')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMCP-019: Enter in the token name field creates the token and shows it once', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/auth/mcp-tokens', () =>
|
||||
HttpResponse.json({
|
||||
token: { id: 9, name: 'CLI', token_prefix: 'trek_cli', created_at: '2025-04-01T00:00:00.000Z', raw_token: 'trek_cli_secret' },
|
||||
})),
|
||||
);
|
||||
renderScreen();
|
||||
await openTokenTab(user);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Create New Token' }));
|
||||
await screen.findByText('Create API Token');
|
||||
expect(screen.getByRole('button', { name: 'Create Token' })).toBeDisabled();
|
||||
|
||||
await user.type(screen.getByPlaceholderText(/Claude Desktop, Work laptop/), 'CLI');
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
await screen.findByText('Token Created');
|
||||
expect(screen.getByText('trek_cli_secret')).toBeInTheDocument();
|
||||
|
||||
const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
|
||||
const copyButtons = screen.getAllByRole('button', { name: 'Copy' });
|
||||
fireEvent.click(copyButtons[copyButtons.length - 1]);
|
||||
expect(writeText).toHaveBeenCalledWith('trek_cli_secret');
|
||||
|
||||
// Escape is refused while the one-time secret is on screen
|
||||
await user.keyboard('{Escape}');
|
||||
expect(screen.getByText('Token Created')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Done' }));
|
||||
await waitFor(() => expect(screen.queryByText('Token Created')).toBeNull());
|
||||
// The new token was prepended to the list
|
||||
expect(screen.getByText('CLI')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMCP-020: a failed token creation toasts the error', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(http.post('/api/auth/mcp-tokens', () => HttpResponse.json({}, { status: 500 })));
|
||||
renderScreen();
|
||||
await openTokenTab(user);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Create New Token' }));
|
||||
await user.type(await screen.findByPlaceholderText(/Claude Desktop, Work laptop/), 'Nope');
|
||||
await user.click(screen.getByRole('button', { name: 'Create Token' }));
|
||||
|
||||
await screen.findByText('Failed to create token');
|
||||
expect(screen.getByText('Create API Token')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMCP-021: cancelling the create-token sheet closes it without a request', async () => {
|
||||
const user = userEvent.setup();
|
||||
let posted = false;
|
||||
server.use(http.post('/api/auth/mcp-tokens', () => { posted = true; return HttpResponse.json({}); }));
|
||||
renderScreen();
|
||||
await openTokenTab(user);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Create New Token' }));
|
||||
await screen.findByText('Create API Token');
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Create API Token')).toBeNull());
|
||||
expect(posted).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMCP-022: deleting a token removes it and toasts', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedApi({ tokens: [TOKEN] });
|
||||
server.use(http.delete('/api/auth/mcp-tokens/5', () => HttpResponse.json({ success: true })));
|
||||
renderScreen();
|
||||
await openTokenTab(user);
|
||||
|
||||
await screen.findByText('Work laptop');
|
||||
await user.click(screen.getByRole('button', { name: 'Delete Token' }));
|
||||
await screen.findByText('This token will stop working immediately. Any MCP client using it will lose access.');
|
||||
|
||||
const confirms = screen.getAllByRole('button', { name: 'Delete Token' });
|
||||
await user.click(confirms[confirms.length - 1]);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Work laptop')).toBeNull());
|
||||
expect(screen.getByText('Token deleted')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMCP-023: a failed token delete keeps the row and toasts', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedApi({ tokens: [TOKEN] });
|
||||
server.use(http.delete('/api/auth/mcp-tokens/5', () => HttpResponse.json({}, { status: 500 })));
|
||||
renderScreen();
|
||||
await openTokenTab(user);
|
||||
|
||||
await screen.findByText('Work laptop');
|
||||
await user.click(screen.getByRole('button', { name: 'Delete Token' }));
|
||||
const confirms = await screen.findAllByRole('button', { name: 'Delete Token' });
|
||||
await user.click(confirms[confirms.length - 1]);
|
||||
|
||||
await screen.findByText('Failed to delete token');
|
||||
expect(screen.getByText('Work laptop')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMCP-024: cancelling the create-client sheet discards the draft', async () => {
|
||||
const user = userEvent.setup();
|
||||
let posted = false;
|
||||
server.use(http.post('/api/oauth/clients', () => { posted = true; return HttpResponse.json({}); }));
|
||||
renderScreen();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'New Client' }));
|
||||
await user.type(await screen.findByPlaceholderText(/Claude Web, My MCP App/), 'Draft');
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Register OAuth Client')).toBeNull());
|
||||
expect(posted).toBe(false);
|
||||
|
||||
// Re-opening resets the form
|
||||
await user.click(screen.getByRole('button', { name: 'New Client' }));
|
||||
expect(await screen.findByPlaceholderText(/Claude Web, My MCP App/)).toHaveValue('');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMCP-025: cancelling the client confirm sheets performs no request', async () => {
|
||||
const user = userEvent.setup();
|
||||
let calls = 0;
|
||||
seedApi({ clients: [OAUTH_CLIENT], sessions: [SESSION] });
|
||||
server.use(
|
||||
http.delete('/api/oauth/clients/cl-1', () => { calls += 1; return HttpResponse.json({}); }),
|
||||
http.post('/api/oauth/clients/cl-1/rotate', () => { calls += 1; return HttpResponse.json({ client_secret: 'x' }); }),
|
||||
http.delete('/api/oauth/sessions/77', () => { calls += 1; return HttpResponse.json({}); }),
|
||||
);
|
||||
renderScreen();
|
||||
await screen.findByText('Claude Web');
|
||||
|
||||
for (const trigger of ['Delete Client', 'Rotate Secret', 'Revoke']) {
|
||||
await user.click(screen.getByRole('button', { name: trigger }));
|
||||
await user.click(await screen.findByRole('button', { name: 'Cancel' }));
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull());
|
||||
}
|
||||
|
||||
expect(calls).toBe(0);
|
||||
expect(screen.getByText('Claude Web')).toBeInTheDocument();
|
||||
expect(screen.getByText('Claude Desktop')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMCP-026: cancelling the delete-token confirm keeps the token', async () => {
|
||||
const user = userEvent.setup();
|
||||
let deleted = false;
|
||||
seedApi({ tokens: [TOKEN] });
|
||||
server.use(http.delete('/api/auth/mcp-tokens/5', () => { deleted = true; return HttpResponse.json({}); }));
|
||||
renderScreen();
|
||||
await openTokenTab(user);
|
||||
|
||||
await screen.findByText('Work laptop');
|
||||
await user.click(screen.getByRole('button', { name: 'Delete Token' }));
|
||||
await user.click(await screen.findByRole('button', { name: 'Cancel' }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull());
|
||||
expect(deleted).toBe(false);
|
||||
expect(screen.getByText('Work laptop')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMCP-027: the rotated-secret sheet dismisses on Escape', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedApi({ clients: [OAUTH_CLIENT] });
|
||||
server.use(http.post('/api/oauth/clients/cl-1/rotate', () => HttpResponse.json({ client_secret: 'rotated-esc' })));
|
||||
renderScreen();
|
||||
|
||||
await screen.findByText('Claude Web');
|
||||
await user.click(screen.getByRole('button', { name: 'Rotate Secret' }));
|
||||
await user.click(await screen.findByRole('button', { name: 'Rotate' }));
|
||||
await screen.findByText('rotated-esc');
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
await waitFor(() => expect(screen.queryByText('rotated-esc')).toBeNull());
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMCP-028: failing list requests leave the empty states in place', async () => {
|
||||
server.use(
|
||||
http.get('/api/oauth/clients', () => HttpResponse.json({}, { status: 500 })),
|
||||
http.get('/api/oauth/sessions', () => HttpResponse.json({}, { status: 500 })),
|
||||
http.get('/api/auth/mcp-tokens', () => HttpResponse.json({}, { status: 500 })),
|
||||
);
|
||||
renderScreen();
|
||||
|
||||
await waitFor(() => expect(screen.getByText('No OAuth clients registered.')).toBeInTheDocument());
|
||||
expect(screen.queryByText('Active OAuth Sessions')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,559 @@
|
||||
// FE-MOB-SETNOTIF-001 onwards
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { render, screen, waitFor, within } from '../../../helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store';
|
||||
import { buildUser } from '../../../helpers/factories';
|
||||
import { server } from '../../../helpers/msw/server';
|
||||
import { useAuthStore } from '../../../../src/store/authStore';
|
||||
import { ToastContainer } from '../../../../src/components/shared/Toast';
|
||||
import MSettingsNotifications from '../../../../src/mobile/screens/settings/MSettingsNotifications';
|
||||
|
||||
const MASKED = '••••••••';
|
||||
|
||||
interface Channel {
|
||||
id: string;
|
||||
source: 'builtin' | 'plugin';
|
||||
labelKey?: string;
|
||||
label?: string;
|
||||
settingsPath?: string;
|
||||
active: boolean;
|
||||
configured: boolean;
|
||||
}
|
||||
|
||||
const builtin = (id: string, over: Partial<Channel> = {}): Channel => ({
|
||||
id,
|
||||
source: 'builtin',
|
||||
labelKey: `settings.notificationPreferences.${id}`,
|
||||
active: true,
|
||||
configured: true,
|
||||
...over,
|
||||
});
|
||||
|
||||
function matrix(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
preferences: { trip_invite: { inapp: true, webhook: false } },
|
||||
channels: [builtin('inapp'), builtin('webhook')],
|
||||
event_types: ['trip_invite'],
|
||||
implemented_combos: { trip_invite: ['inapp', 'webhook'] },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function usePrefs(payload: unknown) {
|
||||
server.use(http.get('/api/notifications/preferences', () => HttpResponse.json(payload)));
|
||||
}
|
||||
|
||||
function useSettings(settings: Record<string, unknown>) {
|
||||
server.use(http.get('/api/settings', () => HttpResponse.json({ settings })));
|
||||
}
|
||||
|
||||
/** Renders the current router location so navigate() calls are observable. */
|
||||
function LocationProbe() {
|
||||
const loc = useLocation();
|
||||
return <span data-testid="loc">{`${loc.pathname}${loc.search}`}</span>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
usePrefs(matrix());
|
||||
useSettings({});
|
||||
server.use(
|
||||
http.put('/api/notifications/preferences', () => HttpResponse.json({ success: true })),
|
||||
http.put('/api/settings', () => HttpResponse.json({ success: true })),
|
||||
http.post('/api/settings/bulk', () => HttpResponse.json({ success: true })),
|
||||
http.post('/api/notifications/test-webhook', () => HttpResponse.json({ success: true })),
|
||||
http.post('/api/notifications/test-ntfy', () => HttpResponse.json({ success: true })),
|
||||
);
|
||||
});
|
||||
|
||||
describe('MSettingsNotifications', () => {
|
||||
it('FE-MOB-SETNOTIF-001: shows the loading line until the matrix arrives', async () => {
|
||||
usePrefs(new Promise(() => {}));
|
||||
render(<MSettingsNotifications />);
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNOTIF-002: a failing preferences load keeps the loading line instead of crashing', async () => {
|
||||
server.use(
|
||||
http.get('/api/notifications/preferences', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
|
||||
http.get('/api/settings', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
|
||||
);
|
||||
render(<MSettingsNotifications />);
|
||||
await waitFor(() => expect(screen.getByText('Loading...')).toBeInTheDocument());
|
||||
expect(screen.getByText('Notifications')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNOTIF-003: reports when no active channel implements any event', async () => {
|
||||
usePrefs(matrix({ implemented_combos: { trip_invite: [] } }));
|
||||
render(<MSettingsNotifications />);
|
||||
expect(await screen.findByText(/No notification channels are configured/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNOTIF-004: an inactive channel is neither a card nor a chip', async () => {
|
||||
usePrefs(matrix({ channels: [builtin('inapp'), builtin('webhook', { active: false })] }));
|
||||
render(<MSettingsNotifications />);
|
||||
|
||||
await screen.findByText('Trip invitations');
|
||||
expect(screen.getByRole('button', { name: 'In-App' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Webhook' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Webhook URL')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNOTIF-005: the webhook card renders with an unset placeholder and a disabled Test', async () => {
|
||||
render(<MSettingsNotifications />);
|
||||
|
||||
expect(await screen.findByText('Webhook URL')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Enter your Discord, Slack, or custom webhook URL/)).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('https://discord.com/api/webhooks/...')).toHaveValue('');
|
||||
expect(screen.getByRole('button', { name: 'Test' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNOTIF-006: an already-stored webhook is masked, never echoed into the field', async () => {
|
||||
useSettings({ webhook_url: MASKED });
|
||||
render(<MSettingsNotifications />);
|
||||
|
||||
const input = await screen.findByPlaceholderText(MASKED);
|
||||
expect(input).toHaveValue('');
|
||||
// A stored URL is enough to test against, even with the field left blank.
|
||||
expect(screen.getByRole('button', { name: 'Test' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNOTIF-007: saving the webhook writes the setting and toasts', async () => {
|
||||
const user = userEvent.setup();
|
||||
let body: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.put('/api/settings', async ({ request }) => {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><MSettingsNotifications /></>);
|
||||
|
||||
const input = await screen.findByPlaceholderText('https://discord.com/api/webhooks/...');
|
||||
await user.type(input, 'https://hooks.example.com/x');
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
expect(await screen.findByText('Webhook URL saved')).toBeInTheDocument();
|
||||
expect(body).toEqual({ key: 'webhook_url', value: 'https://hooks.example.com/x' });
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNOTIF-008: a rejected webhook save toasts the generic error', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(http.put('/api/settings', () => HttpResponse.json({ error: 'nope' }, { status: 500 })));
|
||||
render(<><ToastContainer /><MSettingsNotifications /></>);
|
||||
|
||||
await screen.findByPlaceholderText('https://discord.com/api/webhooks/...');
|
||||
const save = screen.getByRole('button', { name: 'Save' });
|
||||
await user.click(save);
|
||||
|
||||
expect(await screen.findByText('Error')).toBeInTheDocument();
|
||||
await waitFor(() => expect(save).toBeEnabled());
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNOTIF-009: testing the webhook posts the typed URL and toasts on success', async () => {
|
||||
const user = userEvent.setup();
|
||||
let body: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.post('/api/notifications/test-webhook', async ({ request }) => {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><MSettingsNotifications /></>);
|
||||
|
||||
await user.type(await screen.findByPlaceholderText('https://discord.com/api/webhooks/...'), 'https://hooks.example.com/x');
|
||||
await user.click(screen.getByRole('button', { name: 'Test' }));
|
||||
|
||||
expect(await screen.findByText('Test webhook sent successfully')).toBeInTheDocument();
|
||||
expect(body).toEqual({ url: 'https://hooks.example.com/x' });
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNOTIF-010: a webhook test answering success:false shows the server error', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(http.post('/api/notifications/test-webhook', () => HttpResponse.json({ success: false, error: 'Connection refused' })));
|
||||
useSettings({ webhook_url: 'https://hooks.example.com/x' });
|
||||
render(<><ToastContainer /><MSettingsNotifications /></>);
|
||||
|
||||
await screen.findByDisplayValue('https://hooks.example.com/x');
|
||||
await user.click(screen.getByRole('button', { name: 'Test' }));
|
||||
|
||||
expect(await screen.findByText('Connection refused')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNOTIF-011: a webhook test request failure falls back to the generic message', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(http.post('/api/notifications/test-webhook', () => HttpResponse.json({ error: 'x' }, { status: 500 })));
|
||||
useSettings({ webhook_url: 'https://hooks.example.com/x' });
|
||||
render(<><ToastContainer /><MSettingsNotifications /></>);
|
||||
|
||||
await screen.findByDisplayValue('https://hooks.example.com/x');
|
||||
const test = screen.getByRole('button', { name: 'Test' });
|
||||
await user.click(test);
|
||||
|
||||
expect(await screen.findByText('Test webhook failed')).toBeInTheDocument();
|
||||
await waitFor(() => expect(test).toBeEnabled());
|
||||
});
|
||||
|
||||
describe('ntfy channel', () => {
|
||||
beforeEach(() => {
|
||||
usePrefs(
|
||||
matrix({
|
||||
preferences: { trip_invite: { inapp: true, ntfy: true } },
|
||||
channels: [builtin('inapp'), builtin('ntfy')],
|
||||
implemented_combos: { trip_invite: ['inapp', 'ntfy'] },
|
||||
defaults: { ntfyServer: 'https://ntfy.admin.example' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNOTIF-012: hydrates topic and server and offers the admin default as placeholder', async () => {
|
||||
useSettings({ ntfy_topic: 'my-topic', ntfy_server: '' });
|
||||
render(<MSettingsNotifications />);
|
||||
|
||||
expect(await screen.findByDisplayValue('my-topic')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('https://ntfy.admin.example')).toHaveValue('');
|
||||
expect(screen.queryByRole('button', { name: 'Clear' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNOTIF-013: a stored access token is masked and can be cleared', async () => {
|
||||
const user = userEvent.setup();
|
||||
let body: Record<string, unknown> | null = null;
|
||||
useSettings({ ntfy_topic: 'my-topic', ntfy_token: MASKED });
|
||||
server.use(
|
||||
http.put('/api/settings', async ({ request }) => {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><MSettingsNotifications /></>);
|
||||
|
||||
expect(await screen.findByPlaceholderText(MASKED)).toHaveValue('');
|
||||
await user.click(screen.getByRole('button', { name: 'Clear' }));
|
||||
|
||||
expect(await screen.findByText('Access token cleared')).toBeInTheDocument();
|
||||
expect(body).toEqual({ key: 'ntfy_token', value: '' });
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: 'Clear' })).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNOTIF-014: a failing token clear keeps the button and toasts', async () => {
|
||||
const user = userEvent.setup();
|
||||
useSettings({ ntfy_topic: 'my-topic', ntfy_token: MASKED });
|
||||
server.use(http.put('/api/settings', () => HttpResponse.json({ error: 'nope' }, { status: 500 })));
|
||||
render(<><ToastContainer /><MSettingsNotifications /></>);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Clear' }));
|
||||
|
||||
expect(await screen.findByText('Error')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Clear' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNOTIF-015: saving sends topic, server and a freshly typed token in one bulk write', async () => {
|
||||
const user = userEvent.setup();
|
||||
let body: { settings?: Record<string, unknown> } | null = null;
|
||||
useSettings({ ntfy_topic: 'my-topic', ntfy_server: 'https://ntfy.sh' });
|
||||
server.use(
|
||||
http.post('/api/settings/bulk', async ({ request }) => {
|
||||
body = (await request.json()) as { settings?: Record<string, unknown> };
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><MSettingsNotifications /></>);
|
||||
|
||||
await screen.findByDisplayValue('my-topic');
|
||||
await user.type(screen.getByPlaceholderText(''), 'tk_abc');
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
expect(await screen.findByText('Ntfy settings saved')).toBeInTheDocument();
|
||||
expect(body?.settings).toEqual({ ntfy_topic: 'my-topic', ntfy_server: 'https://ntfy.sh', ntfy_token: 'tk_abc' });
|
||||
// The token is now stored, so the Clear action appears.
|
||||
expect(screen.getByRole('button', { name: 'Clear' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNOTIF-016: saving without a token omits it from the payload', async () => {
|
||||
const user = userEvent.setup();
|
||||
let body: { settings?: Record<string, unknown> } | null = null;
|
||||
useSettings({ ntfy_topic: 'my-topic' });
|
||||
server.use(
|
||||
http.post('/api/settings/bulk', async ({ request }) => {
|
||||
body = (await request.json()) as { settings?: Record<string, unknown> };
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
render(<MSettingsNotifications />);
|
||||
|
||||
await screen.findByDisplayValue('my-topic');
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => expect(body).not.toBeNull());
|
||||
expect(body?.settings).toEqual({ ntfy_topic: 'my-topic', ntfy_server: '' });
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNOTIF-017: a rejected ntfy save toasts the generic error', async () => {
|
||||
const user = userEvent.setup();
|
||||
useSettings({ ntfy_topic: 'my-topic' });
|
||||
server.use(http.post('/api/settings/bulk', () => HttpResponse.json({ error: 'nope' }, { status: 500 })));
|
||||
render(<><ToastContainer /><MSettingsNotifications /></>);
|
||||
|
||||
await screen.findByDisplayValue('my-topic');
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
expect(await screen.findByText('Error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNOTIF-018: Test needs a topic and sends the current topic, server and token', async () => {
|
||||
const user = userEvent.setup();
|
||||
let body: Record<string, unknown> | null = null;
|
||||
useSettings({});
|
||||
server.use(
|
||||
http.post('/api/notifications/test-ntfy', async ({ request }) => {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><MSettingsNotifications /></>);
|
||||
|
||||
const test = await screen.findByRole('button', { name: 'Test' });
|
||||
expect(test).toBeDisabled();
|
||||
|
||||
await user.type(screen.getByPlaceholderText('my-trek-alerts'), 'alerts');
|
||||
await user.click(test);
|
||||
|
||||
expect(await screen.findByText('Test ntfy notification sent successfully')).toBeInTheDocument();
|
||||
expect(body).toEqual({ topic: 'alerts', server: null, token: null });
|
||||
|
||||
// A typed-in server overrides the admin default for the next test run.
|
||||
await user.type(screen.getByPlaceholderText('https://ntfy.admin.example'), 'https://ntfy.example.org');
|
||||
await user.click(test);
|
||||
await waitFor(() => expect(body).toEqual({ topic: 'alerts', server: 'https://ntfy.example.org', token: null }));
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNOTIF-019: an ntfy test answering success:false shows the server error', async () => {
|
||||
const user = userEvent.setup();
|
||||
useSettings({ ntfy_topic: 'alerts' });
|
||||
server.use(http.post('/api/notifications/test-ntfy', () => HttpResponse.json({ success: false, error: 'Topic unreachable' })));
|
||||
render(<><ToastContainer /><MSettingsNotifications /></>);
|
||||
|
||||
await screen.findByDisplayValue('alerts');
|
||||
await user.click(screen.getByRole('button', { name: 'Test' }));
|
||||
|
||||
expect(await screen.findByText('Topic unreachable')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNOTIF-020: an ntfy test request failure falls back to the generic message', async () => {
|
||||
const user = userEvent.setup();
|
||||
useSettings({ ntfy_topic: 'alerts' });
|
||||
server.use(http.post('/api/notifications/test-ntfy', () => HttpResponse.json({ error: 'x' }, { status: 500 })));
|
||||
render(<><ToastContainer /><MSettingsNotifications /></>);
|
||||
|
||||
await screen.findByDisplayValue('alerts');
|
||||
const test = screen.getByRole('button', { name: 'Test' });
|
||||
await user.click(test);
|
||||
|
||||
expect(await screen.findByText('Test ntfy notification failed')).toBeInTheDocument();
|
||||
await waitFor(() => expect(test).toBeEnabled());
|
||||
});
|
||||
});
|
||||
|
||||
describe('plugin channels', () => {
|
||||
const pluginChannel = (over: Partial<Channel> = {}): Channel => ({
|
||||
id: 'plugin:trek-gotify',
|
||||
source: 'plugin',
|
||||
label: 'Gotify',
|
||||
settingsPath: '/settings?tab=plugins',
|
||||
active: true,
|
||||
configured: true,
|
||||
...over,
|
||||
});
|
||||
|
||||
const pluginMatrix = (over: Partial<Channel> = {}) =>
|
||||
matrix({
|
||||
preferences: { trip_invite: { inapp: true, 'plugin:trek-gotify': false } },
|
||||
channels: [builtin('inapp'), pluginChannel(over)],
|
||||
implemented_combos: { trip_invite: ['inapp', 'plugin:trek-gotify'] },
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNOTIF-021: a configured plugin channel offers a test send and no configure link', async () => {
|
||||
usePrefs(pluginMatrix());
|
||||
render(<MSettingsNotifications />);
|
||||
|
||||
// Once as its own card, once as a chip in the event row.
|
||||
await waitFor(() => expect(screen.getAllByText('Gotify')).toHaveLength(2));
|
||||
expect(screen.getByText(/Configured\. Manage credentials/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Send test' })).toBeEnabled();
|
||||
expect(screen.queryByRole('button', { name: 'Configure' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNOTIF-022: an unconfigured plugin channel links to its settings page and blocks the test', async () => {
|
||||
const user = userEvent.setup();
|
||||
usePrefs(pluginMatrix({ configured: false }));
|
||||
render(<><MSettingsNotifications /><LocationProbe /></>);
|
||||
|
||||
expect(await screen.findByText(/Not configured yet/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Send test' })).toBeDisabled();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Configure' }));
|
||||
expect(screen.getByTestId('loc')).toHaveTextContent('/settings?tab=plugins');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNOTIF-023: Send test hits the generic channel route and toasts', async () => {
|
||||
const user = userEvent.setup();
|
||||
let called = '';
|
||||
usePrefs(pluginMatrix());
|
||||
server.use(
|
||||
http.post('/api/notifications/test/:channelId', ({ params }) => {
|
||||
called = String(params.channelId);
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><MSettingsNotifications /></>);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Send test' }));
|
||||
|
||||
expect(await screen.findByText('Test notification sent.')).toBeInTheDocument();
|
||||
expect(called).toBe('plugin:trek-gotify');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNOTIF-024: a channel test answering success:false surfaces its error', async () => {
|
||||
const user = userEvent.setup();
|
||||
usePrefs(pluginMatrix());
|
||||
server.use(http.post('/api/notifications/test/:channelId', () => HttpResponse.json({ success: false, error: 'Gotify said no' })));
|
||||
render(<><ToastContainer /><MSettingsNotifications /></>);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Send test' }));
|
||||
expect(await screen.findByText('Gotify said no')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNOTIF-025: a channel test request failure falls back to the generic message', async () => {
|
||||
const user = userEvent.setup();
|
||||
usePrefs(pluginMatrix());
|
||||
server.use(http.post('/api/notifications/test/:channelId', () => HttpResponse.json({ error: 'x' }, { status: 500 })));
|
||||
render(<><ToastContainer /><MSettingsNotifications /></>);
|
||||
|
||||
const send = await screen.findByRole('button', { name: 'Send test' });
|
||||
await user.click(send);
|
||||
|
||||
expect(await screen.findByText('Test failed.')).toBeInTheDocument();
|
||||
await waitFor(() => expect(send).toBeEnabled());
|
||||
});
|
||||
});
|
||||
|
||||
describe('event matrix', () => {
|
||||
it('FE-MOB-SETNOTIF-026: tapping a chip flips the preference and PUTs the whole matrix', async () => {
|
||||
const user = userEvent.setup();
|
||||
let body: Record<string, Record<string, boolean>> | null = null;
|
||||
server.use(
|
||||
http.put('/api/notifications/preferences', async ({ request }) => {
|
||||
body = (await request.json()) as Record<string, Record<string, boolean>>;
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
render(<MSettingsNotifications />);
|
||||
|
||||
const chip = await screen.findByRole('button', { name: 'In-App' });
|
||||
await user.click(chip);
|
||||
|
||||
await waitFor(() => expect(body).not.toBeNull());
|
||||
expect(body).toEqual({ trip_invite: { inapp: false, webhook: false } });
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNOTIF-027: a channel with no stored preference defaults to on', async () => {
|
||||
const user = userEvent.setup();
|
||||
let body: Record<string, Record<string, boolean>> | null = null;
|
||||
usePrefs(matrix({ preferences: {} }));
|
||||
server.use(
|
||||
http.put('/api/notifications/preferences', async ({ request }) => {
|
||||
body = (await request.json()) as Record<string, Record<string, boolean>>;
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
render(<MSettingsNotifications />);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Webhook' }));
|
||||
|
||||
await waitFor(() => expect(body).not.toBeNull());
|
||||
expect(body).toEqual({ trip_invite: { webhook: false } });
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNOTIF-028: a failing update rolls the matrix back', async () => {
|
||||
const user = userEvent.setup();
|
||||
let reject!: () => void;
|
||||
server.use(
|
||||
http.put(
|
||||
'/api/notifications/preferences',
|
||||
() => new Promise<Response>(resolve => { reject = () => resolve(HttpResponse.json({ error: 'nope' }, { status: 500 }) as unknown as Response); }),
|
||||
),
|
||||
);
|
||||
render(<MSettingsNotifications />);
|
||||
|
||||
const chip = await screen.findByRole('button', { name: 'In-App' });
|
||||
// The webhook chip is off in this matrix — its look is the "inactive" reference.
|
||||
const inactiveClass = screen.getByRole('button', { name: 'Webhook' }).className;
|
||||
const activeClass = chip.className;
|
||||
expect(activeClass).not.toBe(inactiveClass);
|
||||
|
||||
await user.click(chip);
|
||||
// Flipped optimistically while the request is still in flight…
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'In-App' }).className).toBe(inactiveClass));
|
||||
|
||||
reject();
|
||||
// …and restored once the server rejects it.
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'In-App' }).className).toBe(activeClass));
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNOTIF-029: the saving hint shows while the update is in flight', async () => {
|
||||
const user = userEvent.setup();
|
||||
let release!: () => void;
|
||||
server.use(
|
||||
http.put(
|
||||
'/api/notifications/preferences',
|
||||
() => new Promise<Response>(resolve => { release = () => resolve(HttpResponse.json({ success: true }) as unknown as Response); }),
|
||||
),
|
||||
);
|
||||
render(<MSettingsNotifications />);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'In-App' }));
|
||||
expect(await screen.findByText('Saving...')).toBeInTheDocument();
|
||||
|
||||
release();
|
||||
await waitFor(() => expect(screen.queryByText('Saving...')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNOTIF-030: events without a matching channel are skipped, unknown ones keep their raw key', async () => {
|
||||
usePrefs(
|
||||
matrix({
|
||||
event_types: ['trip_invite', 'booking_change', 'weird_event'],
|
||||
implemented_combos: {
|
||||
trip_invite: ['inapp'],
|
||||
booking_change: ['email'],
|
||||
weird_event: ['webhook'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
render(<MSettingsNotifications />);
|
||||
|
||||
await screen.findByText('Trip invitations');
|
||||
// booking_change only implements the inactive email channel → no row at all.
|
||||
expect(screen.queryByText('Booking changes')).not.toBeInTheDocument();
|
||||
// No label key for weird_event → the raw event type is shown.
|
||||
expect(screen.getByText('weird_event')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNOTIF-031: a plugin channel without a label falls back to its id', async () => {
|
||||
usePrefs(
|
||||
matrix({
|
||||
preferences: { trip_invite: { 'plugin:x': true } },
|
||||
channels: [{ id: 'plugin:x', source: 'plugin', active: true, configured: true }],
|
||||
implemented_combos: { trip_invite: ['plugin:x'] },
|
||||
}),
|
||||
);
|
||||
render(<MSettingsNotifications />);
|
||||
|
||||
const row = (await screen.findByText('Trip invitations')).parentElement as HTMLElement;
|
||||
expect(within(row).getByRole('button', { name: 'plugin:x' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,440 @@
|
||||
// FE-MOB-OFFLINE-001 onwards
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { act, render, screen, waitFor, within } from '../../../helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store';
|
||||
import { buildTrip, buildUser } from '../../../helpers/factories';
|
||||
import { server } from '../../../helpers/msw/server';
|
||||
import { useAuthStore } from '../../../../src/store/authStore';
|
||||
import { _resetNetworkMode } from '../../../../src/sync/networkMode';
|
||||
import { _resetOfflinePrefs, getOfflinePrefs } from '../../../../src/sync/offlinePrefs';
|
||||
import type { QueuedMutation, SyncMeta } from '../../../../src/db/offlineDb';
|
||||
import type { PrepareProgress } from '../../../../src/sync/tripSyncManager';
|
||||
import type { Trip } from '../../../../src/types';
|
||||
import MSettingsOffline from '../../../../src/mobile/screens/settings/MSettingsOffline';
|
||||
|
||||
// Dexie, the sync manager, the mutation queue and the tile cache are the four
|
||||
// side-effecting neighbours of this screen; everything else (network mode,
|
||||
// offline prefs, the trips API) runs for real.
|
||||
const h = vi.hoisted(() => {
|
||||
const syncMetaToArray = vi.fn();
|
||||
const tripsGet = vi.fn();
|
||||
const tripsToArray = vi.fn();
|
||||
const placeCount = vi.fn();
|
||||
const fileCount = vi.fn();
|
||||
const counted = (fn: (id: number) => Promise<number>) => ({
|
||||
where: () => ({ equals: (id: number) => ({ count: () => fn(id) }) }),
|
||||
});
|
||||
return {
|
||||
syncMetaToArray, tripsGet, tripsToArray, placeCount, fileCount,
|
||||
fakeDb: {
|
||||
syncMeta: { toArray: syncMetaToArray },
|
||||
trips: { get: tripsGet, toArray: tripsToArray },
|
||||
places: counted(placeCount),
|
||||
tripFiles: counted(fileCount),
|
||||
},
|
||||
clearAll: vi.fn(),
|
||||
clearTripData: vi.fn(),
|
||||
prepareForOffline: vi.fn(),
|
||||
syncAll: vi.fn(),
|
||||
pendingCount: vi.fn(),
|
||||
failedCount: vi.fn(),
|
||||
conflicts: vi.fn(),
|
||||
resolveKeepMine: vi.fn(),
|
||||
resolveKeepServer: vi.fn(),
|
||||
clearTileCache: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../../src/db/offlineDb', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../../../src/db/offlineDb')>();
|
||||
return { ...actual, offlineDb: h.fakeDb, clearAll: h.clearAll, clearTripData: h.clearTripData };
|
||||
});
|
||||
|
||||
vi.mock('../../../../src/sync/tripSyncManager', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../../../src/sync/tripSyncManager')>();
|
||||
return { ...actual, tripSyncManager: { prepareForOffline: h.prepareForOffline, syncAll: h.syncAll } };
|
||||
});
|
||||
|
||||
vi.mock('../../../../src/sync/mutationQueue', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../../../src/sync/mutationQueue')>();
|
||||
return {
|
||||
...actual,
|
||||
mutationQueue: {
|
||||
pendingCount: h.pendingCount,
|
||||
failedCount: h.failedCount,
|
||||
conflicts: h.conflicts,
|
||||
resolveKeepMine: h.resolveKeepMine,
|
||||
resolveKeepServer: h.resolveKeepServer,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../../src/sync/tilePrefetcher', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../../../src/sync/tilePrefetcher')>();
|
||||
return { ...actual, clearTileCache: h.clearTileCache };
|
||||
});
|
||||
|
||||
const paris = buildTrip({ id: 1, title: 'Paris', start_date: '2025-06-01', end_date: '2025-06-05' });
|
||||
const tokyo = buildTrip({ id: 2, title: 'Tokyo', start_date: '2025-09-01', end_date: '2025-09-15' });
|
||||
|
||||
const meta = (tripId: number, over: Partial<SyncMeta> = {}): SyncMeta => ({
|
||||
tripId,
|
||||
lastSyncedAt: null,
|
||||
status: 'idle',
|
||||
tilesBbox: null,
|
||||
filesCachedCount: 0,
|
||||
...over,
|
||||
});
|
||||
|
||||
const conflict = (over: Partial<QueuedMutation> = {}): QueuedMutation => ({
|
||||
id: 'c1',
|
||||
tripId: 1,
|
||||
method: 'PUT',
|
||||
url: '/trips/1/places/9',
|
||||
body: { name: 'Louvre' },
|
||||
createdAt: 0,
|
||||
status: 'conflict',
|
||||
attempts: 1,
|
||||
lastError: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
/** Seed the Dexie cache with the given (trip, meta) pairs. */
|
||||
function cache(rows: { trip: Trip; meta?: Partial<SyncMeta>; places?: number; files?: number }[]): void {
|
||||
h.syncMetaToArray.mockResolvedValue(rows.map(r => meta(r.trip.id, r.meta)));
|
||||
h.tripsGet.mockImplementation(async (id: number) => rows.find(r => r.trip.id === id)?.trip);
|
||||
h.placeCount.mockImplementation(async (id: number) => rows.find(r => r.trip.id === id)?.places ?? 0);
|
||||
h.fileCount.mockImplementation(async (id: number) => rows.find(r => r.trip.id === id)?.files ?? 0);
|
||||
}
|
||||
|
||||
function setOnLine(value: boolean): void {
|
||||
Object.defineProperty(window.navigator, 'onLine', { value, configurable: true });
|
||||
}
|
||||
|
||||
const card = (title: string) => screen.getByText(title).closest('section') as HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetAllStores();
|
||||
_resetNetworkMode();
|
||||
_resetOfflinePrefs();
|
||||
setOnLine(true);
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
|
||||
h.syncMetaToArray.mockResolvedValue([]);
|
||||
h.tripsGet.mockResolvedValue(undefined);
|
||||
h.tripsToArray.mockResolvedValue([]);
|
||||
h.placeCount.mockResolvedValue(0);
|
||||
h.fileCount.mockResolvedValue(0);
|
||||
h.pendingCount.mockResolvedValue(0);
|
||||
h.failedCount.mockResolvedValue(0);
|
||||
h.conflicts.mockResolvedValue([]);
|
||||
h.clearAll.mockResolvedValue(undefined);
|
||||
h.clearTripData.mockResolvedValue(undefined);
|
||||
h.clearTileCache.mockResolvedValue(undefined);
|
||||
h.prepareForOffline.mockResolvedValue(undefined);
|
||||
h.syncAll.mockResolvedValue(undefined);
|
||||
h.resolveKeepMine.mockResolvedValue(undefined);
|
||||
h.resolveKeepServer.mockResolvedValue(undefined);
|
||||
|
||||
server.use(http.get('/api/trips', () => HttpResponse.json({ trips: [paris, tokyo] })));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setOnLine(true);
|
||||
});
|
||||
|
||||
describe('MSettingsOffline', () => {
|
||||
it('FE-MOB-OFFLINE-001: shows the loading line while the cache is being read', async () => {
|
||||
h.syncMetaToArray.mockReturnValue(new Promise(() => {}));
|
||||
render(<MSettingsOffline />);
|
||||
|
||||
expect(screen.getByText('Loading…')).toBeInTheDocument();
|
||||
expect(screen.getByText('Offline mode')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-OFFLINE-002: an empty cache reports zero stats and disables Clear cache', async () => {
|
||||
render(<MSettingsOffline />);
|
||||
|
||||
expect(await screen.findByText('No trips cached yet. Connect to the internet to sync.')).toBeInTheDocument();
|
||||
const stats = card('Offline cache');
|
||||
expect(within(screen.getByText('Cached trips').parentElement as HTMLElement).getByText('0')).toBeInTheDocument();
|
||||
expect(within(stats).getByRole('button', { name: 'Clear cache' })).toBeDisabled();
|
||||
expect(screen.queryByText('Failed changes')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Conflicts')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-OFFLINE-003: cached trips are listed oldest first with their place and file counts', async () => {
|
||||
cache([
|
||||
{ trip: tokyo, places: 3, files: 1 },
|
||||
{ trip: paris, places: 7, files: 2 },
|
||||
]);
|
||||
render(<MSettingsOffline />);
|
||||
|
||||
const cacheCard = card('Offline cache');
|
||||
await waitFor(() => expect(within(cacheCard).getByText('Paris')).toBeInTheDocument());
|
||||
const titles = within(cacheCard).getAllByText(/^(Paris|Tokyo)$/).map(el => el.textContent);
|
||||
expect(titles).toEqual(['Paris', 'Tokyo']);
|
||||
expect(within(cacheCard).getByText(/Jun 1, 2025 – Jun 5, 2025/)).toHaveTextContent('7');
|
||||
expect(within(screen.getByText('Cached trips').parentElement as HTMLElement).getByText('2')).toBeInTheDocument();
|
||||
// Never synced → the time column shows a dash.
|
||||
expect(within(cacheCard).getAllByText('—').length).toBe(2);
|
||||
});
|
||||
|
||||
it('FE-MOB-OFFLINE-004: a sync-meta row without its trip in Dexie is dropped', async () => {
|
||||
h.syncMetaToArray.mockResolvedValue([meta(1), meta(99)]);
|
||||
h.tripsGet.mockImplementation(async (id: number) => (id === 1 ? paris : undefined));
|
||||
render(<MSettingsOffline />);
|
||||
|
||||
const cacheCard = card('Offline cache');
|
||||
await waitFor(() => expect(within(cacheCard).getByText('Paris')).toBeInTheDocument());
|
||||
expect(within(screen.getByText('Cached trips').parentElement as HTMLElement).getByText('1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-OFFLINE-005: pending, failed and conflict counters only appear when they are non-zero', async () => {
|
||||
h.pendingCount.mockResolvedValue(4);
|
||||
h.failedCount.mockResolvedValue(2);
|
||||
h.conflicts.mockResolvedValue([conflict()]);
|
||||
render(<MSettingsOffline />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Failed changes')).toBeInTheDocument());
|
||||
expect(within(screen.getByText('Pending changes').parentElement as HTMLElement).getByText('4')).toBeInTheDocument();
|
||||
expect(within(screen.getByText('Failed changes').parentElement as HTMLElement).getByText('2')).toBeInTheDocument();
|
||||
expect(within(screen.getByText('Conflicts').parentElement as HTMLElement).getByText('1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-OFFLINE-006: conflicts are named from the queued body, the server copy or the entity id', async () => {
|
||||
h.conflicts.mockResolvedValue([
|
||||
conflict({ id: 'a', body: { name: 'Louvre' } }),
|
||||
conflict({ id: 'b', body: {}, conflictServer: { name: 'Server name' } }),
|
||||
conflict({ id: 'c', body: {}, entityId: 42 }),
|
||||
]);
|
||||
render(<MSettingsOffline />);
|
||||
|
||||
await screen.findByText('Sync conflicts');
|
||||
expect(screen.getByText(/Louvre/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Server name/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/#42/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-OFFLINE-007: keep-mine and keep-theirs resolve the right conflict and reload', async () => {
|
||||
const user = userEvent.setup();
|
||||
h.conflicts.mockResolvedValue([conflict({ id: 'a' }), conflict({ id: 'b', body: { name: 'Eiffel' } })]);
|
||||
render(<MSettingsOffline />);
|
||||
|
||||
const row = (await screen.findByText(/Eiffel/)).parentElement as HTMLElement;
|
||||
await user.click(within(row).getByRole('button', { name: 'Keep mine' }));
|
||||
expect(h.resolveKeepMine).toHaveBeenCalledWith('b');
|
||||
|
||||
await user.click(within(row).getByRole('button', { name: 'Keep theirs' }));
|
||||
expect(h.resolveKeepServer).toHaveBeenCalledWith('b');
|
||||
// Both resolutions re-read the cache (once on mount + once per resolution).
|
||||
await waitFor(() => expect(h.conflicts).toHaveBeenCalledTimes(3));
|
||||
});
|
||||
|
||||
it('FE-MOB-OFFLINE-008: the conflict strategy segments persist the choice', async () => {
|
||||
const user = userEvent.setup();
|
||||
h.conflicts.mockResolvedValue([conflict()]);
|
||||
render(<MSettingsOffline />);
|
||||
|
||||
await screen.findByText('When a conflict happens');
|
||||
expect(screen.getByRole('button', { name: 'Ask me each time' })).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Always keep my version' }));
|
||||
expect(getOfflinePrefs().conflictStrategy).toBe('mine');
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: 'Always keep my version' })).toHaveAttribute('aria-pressed', 'true'),
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-MOB-OFFLINE-009: Prepare renders live progress and the done marker', async () => {
|
||||
const user = userEvent.setup();
|
||||
let release!: () => void;
|
||||
h.prepareForOffline.mockImplementation(async (cb: (p: PrepareProgress) => void) => {
|
||||
cb({ phase: 'files', current: 1, total: 4, label: 'Paris' });
|
||||
await new Promise<void>(resolve => { release = resolve; });
|
||||
cb({ phase: 'done', current: 4, total: 4 });
|
||||
});
|
||||
render(<MSettingsOffline />);
|
||||
|
||||
await screen.findByText('No trips cached yet. Connect to the internet to sync.');
|
||||
await user.click(screen.getByRole('button', { name: 'Download for offline use' }));
|
||||
|
||||
expect(await screen.findByText('Documents · 1/4 · Paris')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Downloading…' })).toBeDisabled();
|
||||
|
||||
await act(async () => { release(); });
|
||||
expect(await screen.findByText('Ready for offline use')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Download for offline use' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('FE-MOB-OFFLINE-010: a progress report without a total renders a full bar', async () => {
|
||||
const user = userEvent.setup();
|
||||
let release!: () => void;
|
||||
h.prepareForOffline.mockImplementation(async (cb: (p: PrepareProgress) => void) => {
|
||||
cb({ phase: 'tiles', current: 0, total: 0 });
|
||||
await new Promise<void>(resolve => { release = resolve; });
|
||||
});
|
||||
render(<MSettingsOffline />);
|
||||
|
||||
await screen.findByText('No trips cached yet. Connect to the internet to sync.');
|
||||
await user.click(screen.getByRole('button', { name: 'Download for offline use' }));
|
||||
|
||||
const label = await screen.findByText('Map tiles · 0/0');
|
||||
const bar = label.previousElementSibling?.firstElementChild as HTMLElement;
|
||||
expect(bar).toHaveStyle({ width: '100%' });
|
||||
await act(async () => { release(); });
|
||||
});
|
||||
|
||||
it('FE-MOB-OFFLINE-011: forcing offline downloads first, then engages and locks the actions', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<MSettingsOffline />);
|
||||
|
||||
await screen.findByText('No trips cached yet. Connect to the internet to sync.');
|
||||
await user.click(screen.getByRole('switch', { name: 'Force offline mode' }));
|
||||
|
||||
expect(h.prepareForOffline).toHaveBeenCalledTimes(1);
|
||||
expect(await screen.findByText(/Offline mode is on/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Download for offline use' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Re-sync now' })).toBeDisabled();
|
||||
|
||||
await user.click(screen.getByRole('switch', { name: 'Force offline mode' }));
|
||||
await waitFor(() => expect(screen.queryByText(/Offline mode is on/)).not.toBeInTheDocument());
|
||||
expect(screen.getByRole('button', { name: 'Re-sync now' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('FE-MOB-OFFLINE-012: forcing offline while already disconnected skips the download', async () => {
|
||||
const user = userEvent.setup();
|
||||
setOnLine(false);
|
||||
render(<MSettingsOffline />);
|
||||
|
||||
await screen.findByText('No trips cached yet. Connect to the internet to sync.');
|
||||
// Already offline: prepare/resync are dead, and flipping the switch cannot download.
|
||||
expect(screen.getByRole('button', { name: 'Download for offline use' })).toBeDisabled();
|
||||
await user.click(screen.getByRole('switch', { name: 'Force offline mode' }));
|
||||
|
||||
expect(h.prepareForOffline).not.toHaveBeenCalled();
|
||||
expect(await screen.findByText(/Offline mode is on/)).toBeInTheDocument();
|
||||
// Offline, the trip list comes out of Dexie instead of the API.
|
||||
expect(h.tripsToArray).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-OFFLINE-013: Re-sync runs syncAll and re-reads the cache', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<MSettingsOffline />);
|
||||
|
||||
await screen.findByText('No trips cached yet. Connect to the internet to sync.');
|
||||
await user.click(screen.getByRole('button', { name: 'Re-sync now' }));
|
||||
|
||||
expect(h.syncAll).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => expect(h.syncMetaToArray).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it('FE-MOB-OFFLINE-014: turning map tiles off drops the tile cache, turning them back on does not', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<MSettingsOffline />);
|
||||
|
||||
const toggle = await screen.findByRole('switch', { name: 'Store map tiles offline' });
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'true');
|
||||
|
||||
await user.click(toggle);
|
||||
expect(getOfflinePrefs().cacheTiles).toBe(false);
|
||||
expect(h.clearTileCache).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => expect(toggle).toHaveAttribute('aria-checked', 'false'));
|
||||
|
||||
await user.click(toggle);
|
||||
expect(getOfflinePrefs().cacheTiles).toBe(true);
|
||||
expect(h.clearTileCache).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-OFFLINE-015: switching a trip off evicts it, switching it back on re-syncs', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<MSettingsOffline />);
|
||||
|
||||
const toggle = await screen.findByRole('switch', { name: 'Tokyo' });
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'true');
|
||||
expect(screen.getAllByText('Stored offline')).toHaveLength(2);
|
||||
|
||||
await user.click(toggle);
|
||||
expect(h.clearTripData).toHaveBeenCalledWith(2);
|
||||
await waitFor(() => expect(screen.getByText('Not stored')).toBeInTheDocument());
|
||||
|
||||
await user.click(toggle);
|
||||
await waitFor(() => expect(h.syncAll).toHaveBeenCalledTimes(1));
|
||||
expect(h.clearTripData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-OFFLINE-016: the per-trip section disappears when there are no trips at all', async () => {
|
||||
server.use(http.get('/api/trips', () => HttpResponse.json({ trips: [] })));
|
||||
render(<MSettingsOffline />);
|
||||
|
||||
await screen.findByText('No trips cached yet. Connect to the internet to sync.');
|
||||
expect(screen.queryByText('Trips')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Store map tiles offline')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-OFFLINE-017: a failing trips API falls back to the Dexie copy', async () => {
|
||||
server.use(http.get('/api/trips', () => HttpResponse.json({ error: 'down' }, { status: 500 })));
|
||||
h.tripsToArray.mockResolvedValue([tokyo, paris]);
|
||||
render(<MSettingsOffline />);
|
||||
|
||||
await screen.findByRole('switch', { name: 'Paris' });
|
||||
// Dexie rows are sorted by start date too.
|
||||
const rows = screen.getAllByRole('switch').map(el => el.getAttribute('aria-label'));
|
||||
expect(rows).toEqual(['Force offline mode', 'Store map tiles offline', 'Paris', 'Tokyo']);
|
||||
});
|
||||
|
||||
it('FE-MOB-OFFLINE-018: when both the API and Dexie fail the trip list is simply empty', async () => {
|
||||
server.use(http.get('/api/trips', () => HttpResponse.json({ error: 'down' }, { status: 500 })));
|
||||
h.tripsToArray.mockRejectedValue(new Error('dexie is gone'));
|
||||
render(<MSettingsOffline />);
|
||||
|
||||
await screen.findByText('No trips cached yet. Connect to the internet to sync.');
|
||||
expect(screen.queryByText('Trips')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-OFFLINE-019: Clear cache asks first and can be cancelled', async () => {
|
||||
const user = userEvent.setup();
|
||||
cache([{ trip: paris }]);
|
||||
render(<MSettingsOffline />);
|
||||
|
||||
await waitFor(() => expect(within(card('Offline cache')).getByRole('button', { name: 'Clear cache' })).toBeEnabled());
|
||||
await user.click(within(card('Offline cache')).getByRole('button', { name: 'Clear cache' }));
|
||||
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
expect(within(dialog).getByText(/Clear all offline trip data/)).toBeInTheDocument();
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Cancel' }));
|
||||
|
||||
expect(h.clearAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-OFFLINE-020: confirming Clear cache wipes the database and reloads', async () => {
|
||||
const user = userEvent.setup();
|
||||
cache([{ trip: paris }]);
|
||||
render(<MSettingsOffline />);
|
||||
|
||||
await waitFor(() => expect(within(card('Offline cache')).getByRole('button', { name: 'Clear cache' })).toBeEnabled());
|
||||
await user.click(within(card('Offline cache')).getByRole('button', { name: 'Clear cache' }));
|
||||
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
h.syncMetaToArray.mockResolvedValue([]);
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Clear cache' }));
|
||||
|
||||
expect(h.clearAll).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => expect(screen.getByText('No trips cached yet. Connect to the internet to sync.')).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-MOB-OFFLINE-021: a synced trip shows its last sync time instead of a dash', async () => {
|
||||
const at = new Date('2025-06-02T09:30:00Z').getTime();
|
||||
cache([{ trip: paris, meta: { lastSyncedAt: at } }]);
|
||||
render(<MSettingsOffline />);
|
||||
|
||||
const cacheCard = card('Offline cache');
|
||||
await waitFor(() => expect(within(cacheCard).getByText('Paris')).toBeInTheDocument());
|
||||
const expected = new Date(at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
expect(within(cacheCard).getByText(expected)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,461 @@
|
||||
// FE-MOB-SETPLG-001 onwards
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { render, screen, waitFor } from '../../../helpers/render';
|
||||
import { server } from '../../../helpers/msw/server';
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store';
|
||||
import { buildSettings } from '../../../helpers/factories';
|
||||
import { useSettingsStore } from '../../../../src/store/settingsStore';
|
||||
import { usePluginStore, type ActivePlugin } from '../../../../src/store/pluginStore';
|
||||
import type { PluginAction, PluginUserSettingField } from '../../../../src/api/client';
|
||||
import { ToastContainer } from '../../../../src/components/shared/Toast';
|
||||
import MSettingsPlugins from '../../../../src/mobile/screens/settings/MSettingsPlugins';
|
||||
|
||||
// The plugin's own settings.html lives behind a sandboxed iframe + postMessage
|
||||
// bridge — out of scope here, so the frame is stubbed to its identifying props.
|
||||
vi.mock('../../../../src/components/Plugins/PluginFrame', () => ({
|
||||
default: ({ pluginId, path, title }: { pluginId: string; path?: string; title?: string }) => (
|
||||
<div data-testid="plugin-frame" data-plugin={pluginId} data-path={path} data-title={title} />
|
||||
),
|
||||
}));
|
||||
|
||||
const SECRET_MASK = '••••••••';
|
||||
|
||||
const PLUGIN: ActivePlugin = { id: 'p1', name: 'Weather Pro', type: 'integration', icon: 'Cloud' };
|
||||
|
||||
const FIELDS: PluginUserSettingField[] = [
|
||||
{ key: 'api_key', label: 'API key', input_type: 'text', secret: true, required: true, placeholder: 'sk-key', hint: 'From the provider dashboard' },
|
||||
{ key: 'endpoint', label: 'Endpoint', input_type: 'text', placeholder: 'https://api.example' },
|
||||
{ key: 'retries', label: 'Retries', input_type: 'number', placeholder: '3' },
|
||||
{ key: 'notify', label: 'Notify me', input_type: 'checkbox', hint: 'Send a push' },
|
||||
{ key: 'mode', label: 'Mode', input_type: 'select', options: [{ value: 'fast', label: 'Fast' }, { value: 'slow', label: 'Slow' }] },
|
||||
{ key: 'raw' },
|
||||
];
|
||||
|
||||
const CONFIG: Record<string, unknown> = {
|
||||
api_key: SECRET_MASK,
|
||||
endpoint: 'https://api.example',
|
||||
retries: 3,
|
||||
notify: true,
|
||||
mode: null,
|
||||
raw: null,
|
||||
};
|
||||
|
||||
const ACTIONS: PluginAction[] = [
|
||||
{ key: 'test', label: 'Test connection', hint: 'Pings the provider', danger: false },
|
||||
{ key: 'reset', label: 'Reset cache', danger: true },
|
||||
];
|
||||
|
||||
const ACTIVITY = [
|
||||
{ ts: '2025-05-01T10:00:00.000Z', plugin_id: 'p1', plugin_name: 'Weather Pro', method: 'GET', resource: '/trips/1', code: 'ok' },
|
||||
{ ts: 'not-a-date', plugin_id: 'p2', plugin_name: null, method: 'POST', resource: null, code: 'FORBIDDEN' },
|
||||
{ ts: '2025-05-02T10:00:00.000Z', plugin_id: 'p3', plugin_name: 'Sync', method: 'PUT', resource: '/atlas', code: 'RATE_LIMIT' },
|
||||
];
|
||||
|
||||
interface SettingsRoute {
|
||||
fields?: PluginUserSettingField[]
|
||||
config?: Record<string, unknown>
|
||||
actions?: PluginAction[]
|
||||
oauth?: { configured: boolean; connected: boolean }
|
||||
}
|
||||
|
||||
function seedPluginApi(id: string, r: SettingsRoute) {
|
||||
server.use(
|
||||
http.get(`/api/plugin-settings/${id}`, () =>
|
||||
HttpResponse.json({ fields: r.fields ?? [], config: r.config ?? {}, actions: r.actions ?? [] })),
|
||||
http.get(`/api/plugin-oauth/${id}/status`, () =>
|
||||
HttpResponse.json(r.oauth ?? { configured: false, connected: false })),
|
||||
);
|
||||
}
|
||||
|
||||
function renderScreen() {
|
||||
return render(<><ToastContainer /><MSettingsPlugins /></>);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }) });
|
||||
usePluginStore.setState({ plugins: [], loaded: true });
|
||||
server.use(http.get('/api/plugin-activity', () => HttpResponse.json({ activity: [] })));
|
||||
});
|
||||
|
||||
describe('MSettingsPlugins', () => {
|
||||
it('FE-MOB-SETPLG-001: without active plugins only the empty note and the activity card render', async () => {
|
||||
renderScreen();
|
||||
|
||||
expect(screen.getByText('No plugins are active.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Plugin activity')).toBeInTheDocument();
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
await screen.findByText('No plugin activity yet.');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETPLG-002: the activity log renders one row per entry with its status tone', async () => {
|
||||
server.use(http.get('/api/plugin-activity', () => HttpResponse.json({ activity: ACTIVITY })));
|
||||
renderScreen();
|
||||
|
||||
await screen.findByText('Weather Pro');
|
||||
// Nameless plugin falls back to its id, missing resource to an em dash
|
||||
expect(screen.getByText('p2')).toBeInTheDocument();
|
||||
expect(screen.getByText('—')).toBeInTheDocument();
|
||||
expect(screen.getByText('/trips/1')).toBeInTheDocument();
|
||||
// An unparsable timestamp is shown verbatim
|
||||
expect(screen.getByText('not-a-date')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('ok')).toHaveClass('text-m-muted');
|
||||
expect(screen.getByText('FORBIDDEN')).toHaveClass('text-[color:var(--m-st-danger)]');
|
||||
expect(screen.getByText('RATE_LIMIT')).toHaveClass('text-[color:var(--m-st-pending)]');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETPLG-003: refresh re-requests the activity log', async () => {
|
||||
let calls = 0;
|
||||
server.use(http.get('/api/plugin-activity', () => { calls += 1; return HttpResponse.json({ activity: ACTIVITY }); }));
|
||||
renderScreen();
|
||||
|
||||
await screen.findByText('Weather Pro');
|
||||
expect(calls).toBe(1);
|
||||
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Refresh' }));
|
||||
await waitFor(() => expect(calls).toBe(2));
|
||||
});
|
||||
|
||||
it('FE-MOB-SETPLG-004: a failing activity request degrades to the empty state', async () => {
|
||||
server.use(http.get('/api/plugin-activity', () => HttpResponse.json({}, { status: 500 })));
|
||||
renderScreen();
|
||||
|
||||
await screen.findByText('No plugin activity yet.');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETPLG-005: declared user fields render as the matching mobile controls', async () => {
|
||||
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
|
||||
seedPluginApi('p1', { fields: FIELDS, config: CONFIG });
|
||||
renderScreen();
|
||||
|
||||
await screen.findByText('Weather Pro');
|
||||
const secret = screen.getByPlaceholderText('sk-key');
|
||||
expect(secret).toHaveAttribute('type', 'password');
|
||||
expect(secret).toHaveValue(SECRET_MASK);
|
||||
expect(screen.getByText('*')).toBeInTheDocument();
|
||||
expect(screen.getByText('From the provider dashboard')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByPlaceholderText('https://api.example')).toHaveAttribute('type', 'text');
|
||||
expect(screen.getByPlaceholderText('3')).toHaveAttribute('type', 'number');
|
||||
expect(screen.getByRole('switch', { name: 'Notify me' })).toHaveAttribute('aria-checked', 'true');
|
||||
// No option matches the null config value
|
||||
expect(screen.getByRole('button', { name: '—' })).toBeInTheDocument();
|
||||
// A field without a label falls back to its key
|
||||
expect(screen.getByText('raw')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETPLG-006: saving posts every field but leaves an untouched secret alone', async () => {
|
||||
const user = userEvent.setup();
|
||||
let body: { config?: Record<string, unknown> } | null = null;
|
||||
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
|
||||
seedPluginApi('p1', { fields: FIELDS, config: CONFIG });
|
||||
server.use(
|
||||
http.post('/api/plugin-settings/p1', async ({ request }) => {
|
||||
body = (await request.json()) as { config: Record<string, unknown> };
|
||||
return HttpResponse.json({ config: { ...CONFIG, endpoint: 'https://new.example' } });
|
||||
}),
|
||||
);
|
||||
renderScreen();
|
||||
|
||||
await screen.findByText('Weather Pro');
|
||||
const endpoint = screen.getByPlaceholderText('https://api.example');
|
||||
await user.clear(endpoint);
|
||||
await user.type(endpoint, 'https://new.example');
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await screen.findByText('Settings saved');
|
||||
expect(body?.config).toEqual({
|
||||
endpoint: 'https://new.example',
|
||||
retries: '3',
|
||||
notify: true,
|
||||
mode: '',
|
||||
raw: '',
|
||||
});
|
||||
expect(body?.config).not.toHaveProperty('api_key');
|
||||
expect(endpoint).toHaveValue('https://new.example');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETPLG-007: an edited secret is included in the patch', async () => {
|
||||
const user = userEvent.setup();
|
||||
let body: { config?: Record<string, unknown> } | null = null;
|
||||
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
|
||||
seedPluginApi('p1', { fields: [FIELDS[0]], config: { api_key: SECRET_MASK } });
|
||||
server.use(
|
||||
http.post('/api/plugin-settings/p1', async ({ request }) => {
|
||||
body = (await request.json()) as { config: Record<string, unknown> };
|
||||
return HttpResponse.json({ config: { api_key: SECRET_MASK } });
|
||||
}),
|
||||
);
|
||||
renderScreen();
|
||||
|
||||
const secret = await screen.findByPlaceholderText('sk-key');
|
||||
await user.clear(secret);
|
||||
await user.type(secret, 'sk-live-123');
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => expect(body?.config).toEqual({ api_key: 'sk-live-123' }));
|
||||
// The response re-masks it
|
||||
expect(secret).toHaveValue(SECRET_MASK);
|
||||
});
|
||||
|
||||
it('FE-MOB-SETPLG-008: the checkbox and select controls feed their new values into the patch', async () => {
|
||||
const user = userEvent.setup();
|
||||
let body: { config?: Record<string, unknown> } | null = null;
|
||||
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
|
||||
seedPluginApi('p1', { fields: [FIELDS[3], FIELDS[4]], config: { notify: true, mode: null } });
|
||||
server.use(
|
||||
http.post('/api/plugin-settings/p1', async ({ request }) => {
|
||||
body = (await request.json()) as { config: Record<string, unknown> };
|
||||
return HttpResponse.json({ config: { notify: false, mode: 'slow' } });
|
||||
}),
|
||||
);
|
||||
renderScreen();
|
||||
|
||||
await screen.findByText('Weather Pro');
|
||||
await user.click(screen.getByRole('switch', { name: 'Notify me' }));
|
||||
await user.click(screen.getByRole('button', { name: '—' }));
|
||||
|
||||
// Picker sheet lists the empty option plus the declared ones
|
||||
await screen.findByRole('button', { name: 'Fast' });
|
||||
await user.click(screen.getByRole('button', { name: 'Slow' }));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await waitFor(() => expect(body?.config).toEqual({ notify: false, mode: 'slow' }));
|
||||
expect(screen.getByRole('switch', { name: 'Notify me' })).toHaveAttribute('aria-checked', 'false');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETPLG-009: a failed save toasts the error and keeps the edit', async () => {
|
||||
const user = userEvent.setup();
|
||||
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
|
||||
seedPluginApi('p1', { fields: [FIELDS[1]], config: { endpoint: 'https://api.example' } });
|
||||
server.use(http.post('/api/plugin-settings/p1', () => HttpResponse.json({}, { status: 500 })));
|
||||
renderScreen();
|
||||
|
||||
const endpoint = await screen.findByPlaceholderText('https://api.example');
|
||||
await user.clear(endpoint);
|
||||
await user.type(endpoint, 'https://broken');
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await screen.findByText('Error');
|
||||
expect(endpoint).toHaveValue('https://broken');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETPLG-010: a plugin with no fields, actions or OAuth renders no card', async () => {
|
||||
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
|
||||
seedPluginApi('p1', {});
|
||||
renderScreen();
|
||||
|
||||
await screen.findByText('No plugin activity yet.');
|
||||
expect(screen.queryByText('Weather Pro')).toBeNull();
|
||||
expect(screen.queryByText('No plugins are active.')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETPLG-011: a failing settings request is fail-safe — no card, no crash', async () => {
|
||||
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
|
||||
server.use(
|
||||
http.get('/api/plugin-settings/p1', () => HttpResponse.json({}, { status: 500 })),
|
||||
http.get('/api/plugin-oauth/p1/status', () => HttpResponse.json({}, { status: 500 })),
|
||||
);
|
||||
renderScreen();
|
||||
|
||||
await screen.findByText('No plugin activity yet.');
|
||||
expect(screen.queryByText('Weather Pro')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETPLG-012: a plain action runs straight away and shows its result', async () => {
|
||||
const user = userEvent.setup();
|
||||
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
|
||||
seedPluginApi('p1', { actions: ACTIONS });
|
||||
server.use(http.post('/api/plugin-settings/p1/actions/test', () => HttpResponse.json({ ok: true, message: 'Reached the provider' })));
|
||||
renderScreen();
|
||||
|
||||
await screen.findByText('Actions');
|
||||
expect(screen.getByText('Pings the provider')).toBeInTheDocument();
|
||||
// Without user fields there is no Save button
|
||||
expect(screen.queryByRole('button', { name: 'Save' })).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Test connection' }));
|
||||
await screen.findByText('Reached the provider');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETPLG-013: an action result without a message falls back to the generic label', async () => {
|
||||
const user = userEvent.setup();
|
||||
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
|
||||
seedPluginApi('p1', { actions: [ACTIONS[0], { key: 'probe', label: 'Probe', danger: false }] });
|
||||
server.use(
|
||||
http.post('/api/plugin-settings/p1/actions/test', () => HttpResponse.json({ ok: true })),
|
||||
http.post('/api/plugin-settings/p1/actions/probe', () => HttpResponse.json({ ok: false })),
|
||||
);
|
||||
renderScreen();
|
||||
|
||||
await screen.findByText('Actions');
|
||||
await user.click(screen.getByRole('button', { name: 'Test connection' }));
|
||||
// 'common.success' has no English string, so t() echoes the key
|
||||
await screen.findByText('common.success');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Probe' }));
|
||||
await screen.findByText('Error');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETPLG-014: unlabelled fields fall back to their key, hints render below', async () => {
|
||||
const user = userEvent.setup();
|
||||
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
|
||||
server.use(
|
||||
// No `actions` key at all — the form must still render.
|
||||
http.get('/api/plugin-settings/p1', () => HttpResponse.json({
|
||||
fields: [
|
||||
{ key: 'flag', input_type: 'checkbox', hint: 'Toggle it' },
|
||||
{ key: 'pick', input_type: 'select', hint: 'Choose one', options: [{ value: 'a', label: 'Alpha' }] },
|
||||
],
|
||||
config: {},
|
||||
})),
|
||||
http.get('/api/plugin-oauth/p1/status', () => HttpResponse.json({ configured: false, connected: false })),
|
||||
);
|
||||
renderScreen();
|
||||
|
||||
await screen.findByText('Weather Pro');
|
||||
expect(screen.getByRole('switch', { name: 'flag' })).toHaveAttribute('aria-checked', 'false');
|
||||
expect(screen.getByText('Toggle it')).toBeInTheDocument();
|
||||
expect(screen.getByText('Choose one')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Actions')).toBeNull();
|
||||
|
||||
// The picker sheet is titled with the key too
|
||||
await user.click(screen.getByRole('button', { name: '—' }));
|
||||
await waitFor(() => expect(screen.getAllByText('pick').length).toBeGreaterThan(1));
|
||||
});
|
||||
|
||||
it('FE-MOB-SETPLG-015: a failing action reports an error result', async () => {
|
||||
const user = userEvent.setup();
|
||||
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
|
||||
seedPluginApi('p1', { actions: [ACTIONS[0]] });
|
||||
server.use(http.post('/api/plugin-settings/p1/actions/test', () => HttpResponse.json({}, { status: 500 })));
|
||||
renderScreen();
|
||||
|
||||
await screen.findByText('Actions');
|
||||
await user.click(screen.getByRole('button', { name: 'Test connection' }));
|
||||
await screen.findByText('Error');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETPLG-016: a danger action asks for confirmation before it runs', async () => {
|
||||
const user = userEvent.setup();
|
||||
let ran = 0;
|
||||
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
|
||||
seedPluginApi('p1', { actions: [ACTIONS[1]] });
|
||||
server.use(http.post('/api/plugin-settings/p1/actions/reset', () => { ran += 1; return HttpResponse.json({ ok: true, message: 'Cache cleared' }); }));
|
||||
renderScreen();
|
||||
|
||||
await screen.findByText('Actions');
|
||||
|
||||
// Cancelling runs nothing
|
||||
await user.click(screen.getByRole('button', { name: 'Reset cache' }));
|
||||
await screen.findByText('Run this action?');
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull());
|
||||
expect(ran).toBe(0);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Reset cache' }));
|
||||
await screen.findByText('Run this action?');
|
||||
const confirms = screen.getAllByRole('button', { name: 'Reset cache' });
|
||||
await user.click(confirms[confirms.length - 1]);
|
||||
|
||||
await screen.findByText('Cache cleared');
|
||||
expect(ran).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-SETPLG-017: an unconfigured OAuth plugin shows no connect controls', async () => {
|
||||
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
|
||||
seedPluginApi('p1', { actions: [ACTIONS[0]], oauth: { configured: false, connected: false } });
|
||||
renderScreen();
|
||||
|
||||
await screen.findByText('Actions');
|
||||
expect(screen.queryByRole('button', { name: 'Connect' })).toBeNull();
|
||||
expect(screen.queryByText('Not connected')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETPLG-018: connecting hands off to the provider authorize URL', async () => {
|
||||
const user = userEvent.setup();
|
||||
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
|
||||
seedPluginApi('p1', { oauth: { configured: true, connected: false } });
|
||||
server.use(http.post('/api/plugin-oauth/p1/connect', () =>
|
||||
HttpResponse.json({ authorizeUrl: `${window.location.origin}/#plugin-authorized` })));
|
||||
renderScreen();
|
||||
|
||||
// The card renders for the OAuth section alone, even without fields
|
||||
await screen.findByText('Weather Pro');
|
||||
expect(screen.getByText('Not connected')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Connect' }));
|
||||
await waitFor(() => expect(window.location.hash).toBe('#plugin-authorized'));
|
||||
window.location.hash = '';
|
||||
});
|
||||
|
||||
it('FE-MOB-SETPLG-019: a failed connect toasts and re-enables the button', async () => {
|
||||
const user = userEvent.setup();
|
||||
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
|
||||
seedPluginApi('p1', { oauth: { configured: true, connected: false } });
|
||||
server.use(http.post('/api/plugin-oauth/p1/connect', () => HttpResponse.json({}, { status: 500 })));
|
||||
renderScreen();
|
||||
|
||||
await screen.findByText('Not connected');
|
||||
await user.click(screen.getByRole('button', { name: 'Connect' }));
|
||||
|
||||
await screen.findByText('Error');
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Connect' })).not.toBeDisabled());
|
||||
});
|
||||
|
||||
it('FE-MOB-SETPLG-020: disconnecting flips the section back to "not connected"', async () => {
|
||||
const user = userEvent.setup();
|
||||
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
|
||||
seedPluginApi('p1', { oauth: { configured: true, connected: true } });
|
||||
server.use(http.post('/api/plugin-oauth/p1/disconnect', () => HttpResponse.json({ connected: false })));
|
||||
renderScreen();
|
||||
|
||||
await screen.findByText('Connected');
|
||||
await user.click(screen.getByRole('button', { name: 'Disconnect' }));
|
||||
|
||||
await screen.findByText('Not connected');
|
||||
expect(screen.getByRole('button', { name: 'Connect' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETPLG-021: a failed disconnect keeps the connected state', async () => {
|
||||
const user = userEvent.setup();
|
||||
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
|
||||
seedPluginApi('p1', { oauth: { configured: true, connected: true } });
|
||||
server.use(http.post('/api/plugin-oauth/p1/disconnect', () => HttpResponse.json({}, { status: 500 })));
|
||||
renderScreen();
|
||||
|
||||
await screen.findByText('Connected');
|
||||
await user.click(screen.getByRole('button', { name: 'Disconnect' }));
|
||||
|
||||
await screen.findByText('Error');
|
||||
expect(screen.getByText('Connected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETPLG-022: a settingsUi plugin gets its own framed card', async () => {
|
||||
usePluginStore.setState({ plugins: [{ ...PLUGIN, settingsUi: true }], loaded: true });
|
||||
seedPluginApi('p1', { fields: [FIELDS[1]], config: { endpoint: 'https://api.example' } });
|
||||
renderScreen();
|
||||
|
||||
const frame = await screen.findByTestId('plugin-frame');
|
||||
expect(frame).toHaveAttribute('data-plugin', 'p1');
|
||||
expect(frame).toHaveAttribute('data-path', 'settings.html');
|
||||
expect(frame).toHaveAttribute('data-title', 'Weather Pro');
|
||||
// Declared-field form and framed surface are two separate cards
|
||||
expect(screen.getAllByText('Weather Pro')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-MOB-SETPLG-023: several active plugins each get their own form', async () => {
|
||||
const second: ActivePlugin = { id: 'p2', name: 'Trip Todos', type: 'widget', icon: null };
|
||||
usePluginStore.setState({ plugins: [PLUGIN, second], loaded: true });
|
||||
seedPluginApi('p1', { fields: [FIELDS[1]], config: { endpoint: 'https://api.example' } });
|
||||
seedPluginApi('p2', { actions: [ACTIONS[0]] });
|
||||
renderScreen();
|
||||
|
||||
await screen.findByText('Weather Pro');
|
||||
expect(await screen.findByText('Trip Todos')).toBeInTheDocument();
|
||||
expect(screen.queryByText('No plugins are active.')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
// FE-MOB-SETUI-001 to FE-MOB-SETUI-019
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { Globe } from 'lucide-react';
|
||||
import { render, screen, fireEvent } from '../../../helpers/render';
|
||||
import {
|
||||
MSetCard,
|
||||
MSetEyebrow,
|
||||
MSetSelectRow,
|
||||
MSetSegments,
|
||||
MSetOnOff,
|
||||
MSetRow,
|
||||
MSetInput,
|
||||
MSetTextarea,
|
||||
MSetButton,
|
||||
MSetHint,
|
||||
} from '../../../../src/mobile/screens/settings/MSettingsUi';
|
||||
|
||||
describe('MSetCard', () => {
|
||||
it('FE-MOB-SETUI-001: renders the title row with a 16px icon and the children below', () => {
|
||||
const { container } = render(
|
||||
<MSetCard title="Language & region" icon={Globe}>
|
||||
<p>card body</p>
|
||||
</MSetCard>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Language & region')).toBeInTheDocument();
|
||||
expect(screen.getByText('card body')).toBeInTheDocument();
|
||||
const icon = container.querySelector('svg');
|
||||
expect(icon).toHaveAttribute('width', '16');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETUI-002: renders the trailing badge slot and keeps the extra class on the section', () => {
|
||||
const { container } = render(
|
||||
<MSetCard title="Addons" icon={Globe} badge={<span>3</span>} className="mt-3">
|
||||
<p>body</p>
|
||||
</MSetCard>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('3')).toBeInTheDocument();
|
||||
expect(container.querySelector('section')).toHaveClass('mt-3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MSetEyebrow', () => {
|
||||
it('FE-MOB-SETUI-003: renders its label and merges the caller class', () => {
|
||||
const { container } = render(<MSetEyebrow className="mb-1">Currency</MSetEyebrow>);
|
||||
|
||||
expect(screen.getByText('Currency')).toBeInTheDocument();
|
||||
expect(container.firstElementChild).toHaveClass('mb-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MSetSelectRow', () => {
|
||||
it('FE-MOB-SETUI-004: shows label plus trailing value and reports the tap', () => {
|
||||
const onClick = vi.fn();
|
||||
render(<MSetSelectRow label="Language" trailing={<span>English</span>} onClick={onClick} />);
|
||||
|
||||
const row = screen.getByRole('button', { name: /Language/ });
|
||||
expect(row).toHaveTextContent('English');
|
||||
fireEvent.click(row);
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-SETUI-005: is a plain type=button so it never submits a surrounding form', () => {
|
||||
render(<MSetSelectRow label="Timezone" />);
|
||||
|
||||
const row = screen.getByRole('button', { name: 'Timezone' });
|
||||
expect(row).toHaveAttribute('type', 'button');
|
||||
fireEvent.click(row);
|
||||
expect(row).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MSetSegments', () => {
|
||||
const options = [
|
||||
{ value: 'celsius', label: '°C' },
|
||||
{ value: 'fahrenheit', label: '°F' },
|
||||
];
|
||||
|
||||
it('FE-MOB-SETUI-006: marks only the current value as pressed', () => {
|
||||
render(<MSetSegments options={options} value="fahrenheit" onChange={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: '°F' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: '°C' })).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETUI-007: reports the tapped option value', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<MSetSegments options={options} value="fahrenheit" onChange={onChange} className="mt-2" />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '°C' }));
|
||||
expect(onChange).toHaveBeenCalledWith('celsius');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MSetOnOff', () => {
|
||||
it('FE-MOB-SETUI-008: exposes both segments under the group label with the current state pressed', () => {
|
||||
render(<MSetOnOff on onChange={vi.fn()} onLabel="On" offLabel="Off" ariaLabel="Show traffic" />);
|
||||
|
||||
const group = screen.getByRole('group', { name: 'Show traffic' });
|
||||
expect(group).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'On' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: 'Off' })).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETUI-009: reports true from the On segment and false from the Off segment', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<MSetOnOff on={false} onChange={onChange} onLabel="On" offLabel="Off" />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'On' }));
|
||||
expect(onChange).toHaveBeenLastCalledWith(true);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Off' }));
|
||||
expect(onChange).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MSetRow', () => {
|
||||
it('FE-MOB-SETUI-010: renders label, sub line and the trailing control', () => {
|
||||
render(<MSetRow label="Distance" sub="Kilometres or miles" trailing={<button type="button">km</button>} />);
|
||||
|
||||
expect(screen.getByText('Distance')).toBeInTheDocument();
|
||||
expect(screen.getByText('Kilometres or miles')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'km' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETUI-011: only rows after the first carry the divider', () => {
|
||||
const { container: first } = render(<MSetRow label="First" first />);
|
||||
expect(first.firstElementChild).not.toHaveClass('border-t');
|
||||
|
||||
const { container: second } = render(<MSetRow label="Second" />);
|
||||
expect(second.firstElementChild).toHaveClass('border-t');
|
||||
// Without a sub prop the row stays a single line of text.
|
||||
expect(second.textContent).toBe('Second');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MSetInput / MSetTextarea', () => {
|
||||
it('FE-MOB-SETUI-012: forwards type, placeholder and change events', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<MSetInput type="email" placeholder="Email" value="a@b.c" onChange={onChange} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Email') as HTMLInputElement;
|
||||
expect(input.type).toBe('email');
|
||||
expect(input.value).toBe('a@b.c');
|
||||
|
||||
fireEvent.change(input, { target: { value: 'x@y.z' } });
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-SETUI-013: mono switches the face and the mono flag never leaks into the DOM', () => {
|
||||
render(<MSetInput mono readOnly value="ABC123" className="mt-2" aria-label="Secret" />);
|
||||
|
||||
const input = screen.getByLabelText('Secret');
|
||||
expect(input).toHaveClass('font-mono', 'mt-2');
|
||||
expect(input).not.toHaveAttribute('mono');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETUI-014: the textarea forwards rows, value and change events', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<MSetTextarea rows={4} value="notes" onChange={onChange} aria-label="Notes" className="mt-2" />);
|
||||
|
||||
const area = screen.getByLabelText('Notes') as HTMLTextAreaElement;
|
||||
expect(area.rows).toBe(4);
|
||||
expect(area.value).toBe('notes');
|
||||
expect(area).toHaveClass('mt-2');
|
||||
|
||||
fireEvent.change(area, { target: { value: 'more' } });
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MSetButton', () => {
|
||||
it('FE-MOB-SETUI-015: the primary variant is the accent-filled pill', () => {
|
||||
render(<MSetButton onClick={vi.fn()}>Save</MSetButton>);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Save' });
|
||||
expect(button).toHaveClass('bg-m-act', 'text-m-actfg');
|
||||
expect(button).toHaveAttribute('type', 'button');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETUI-016: the danger variant uses the danger ink, ghost stays neutral', () => {
|
||||
render(
|
||||
<>
|
||||
<MSetButton variant="danger">Delete</MSetButton>
|
||||
<MSetButton variant="ghost">Cancel</MSetButton>
|
||||
</>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Delete' })).toHaveClass('text-[color:var(--m-st-danger)]');
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toHaveClass('text-m-ink');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETUI-017: a click reaches onClick', () => {
|
||||
const onClick = vi.fn();
|
||||
render(<MSetButton onClick={onClick} className="mt-3">Update</MSetButton>);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Update' }));
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-SETUI-018: a disabled button swallows the click', () => {
|
||||
const onClick = vi.fn();
|
||||
render(<MSetButton onClick={onClick} disabled>Update</MSetButton>);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Update' });
|
||||
expect(button).toBeDisabled();
|
||||
fireEvent.click(button);
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MSetHint', () => {
|
||||
it('FE-MOB-SETUI-019: renders the hint text with the caller class', () => {
|
||||
const { container } = render(<MSetHint className="mb-2">Stored encrypted.</MSetHint>);
|
||||
|
||||
expect(screen.getByText('Stored encrypted.')).toBeInTheDocument();
|
||||
expect(container.querySelector('p')).toHaveClass('mb-2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,370 @@
|
||||
// FE-MOB-MVACSCR-001 to FE-MOB-MVACSCR-016
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, within } from '../../../helpers/render';
|
||||
import MVacay from '../../../../src/mobile/screens/vacay/MVacay';
|
||||
import type { DayVisualContext } from '../../../../src/mobile/screens/vacay/vacayDayModel';
|
||||
|
||||
const mocks = vi.hoisted(() => ({ v: {} as Record<string, unknown> }));
|
||||
|
||||
vi.mock('../../../../src/mobile/screens/vacay/useMVacay', () => ({
|
||||
useMVacay: () => mocks.v,
|
||||
}));
|
||||
|
||||
interface SheetStubProps { open: boolean; onClose: () => void }
|
||||
|
||||
vi.mock('../../../../src/mobile/screens/vacay/MVacayInviteSheet', () => ({
|
||||
default: ({ open, onClose }: SheetStubProps) => (
|
||||
<div data-testid="invite-sheet" data-open={String(open)}>
|
||||
<button type="button" onClick={onClose}>close-invite</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock('../../../../src/mobile/screens/vacay/MVacaySettingsSheet', () => ({
|
||||
default: ({ open, onClose }: SheetStubProps) => (
|
||||
<div data-testid="settings-sheet" data-open={String(open)}>
|
||||
<button type="button" onClick={onClose}>close-settings</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock('../../../../src/mobile/screens/vacay/MVacayShareSheet', () => ({
|
||||
default: ({ open, onClose }: SheetStubProps) => (
|
||||
<div data-testid="share-sheet" data-open={String(open)}>
|
||||
<button type="button" onClick={onClose}>close-share</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const dayCtx: DayVisualContext = {
|
||||
todayStr: '2099-01-01',
|
||||
entryMap: {},
|
||||
companyHolidaySet: new Set(),
|
||||
companyHolidaysEnabled: true,
|
||||
holidays: {},
|
||||
weekendDays: [0, 6],
|
||||
};
|
||||
|
||||
const MONTHS_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
/** The screen flips the modifier flags with an updater, so the mock has to run it. */
|
||||
function boolSetter() {
|
||||
return vi.fn((update: boolean | ((prev: boolean) => boolean)) => {
|
||||
if (typeof update === 'function') update(false);
|
||||
});
|
||||
}
|
||||
|
||||
function buildV(over: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
loading: false,
|
||||
plan: { id: 1, holiday_calendars: [] },
|
||||
selectedYear: 2026,
|
||||
users: [
|
||||
{ id: 1, username: 'alice', color: '#3b82f6' },
|
||||
{ id: 2, username: 'bob', color: null },
|
||||
],
|
||||
isFused: false,
|
||||
currentUser: { id: 1, username: 'alice' },
|
||||
incomingInvites: [],
|
||||
acceptInvite: vi.fn((_planId: number) => undefined),
|
||||
declineInvite: vi.fn((_planId: number) => undefined),
|
||||
incomingShares: [],
|
||||
toggleShareHidden: vi.fn((_id: number, _hidden: boolean) => undefined),
|
||||
view: 'grid',
|
||||
months: Array.from({ length: 12 }, (_, m) => ({ year: 2026, month: m })),
|
||||
monthSlot: 5,
|
||||
activeMonth: { year: 2026, month: 5 },
|
||||
isShiftedYear: false,
|
||||
mode: 'vacation',
|
||||
halfDay: false,
|
||||
setHalfDay: boolSetter(),
|
||||
compDay: false,
|
||||
setCompDay: boolSetter(),
|
||||
sheet: null,
|
||||
setSheet: vi.fn(() => undefined),
|
||||
setMode: vi.fn(() => undefined),
|
||||
setMonthSlot: vi.fn((_slot: number) => undefined),
|
||||
tripDates: new Set<string>(),
|
||||
tripDotColor: '#3b82f6',
|
||||
blockWeekends: true,
|
||||
companyHolidaysEnabled: true,
|
||||
holidaysEnabled: false,
|
||||
weekStart: 1,
|
||||
weekendDays: [0, 6],
|
||||
dayCtx,
|
||||
monthNamesShort: MONTHS_SHORT,
|
||||
monthNameLong: 'June',
|
||||
selectedUser: { id: 1, username: 'alice', color: '#3b82f6' },
|
||||
selectedColor: '#3b82f6',
|
||||
selectedStat: { user_id: 1, vacation_days: 30, used: 4, total_available: 30, remaining: 26, carried_over: 0 },
|
||||
selectedUserId: 1,
|
||||
selectPerson: vi.fn((_id: number) => undefined),
|
||||
handleDayTap: vi.fn(async (_date: string) => {}),
|
||||
allowInc: vi.fn(() => undefined),
|
||||
allowDec: vi.fn(() => undefined),
|
||||
prevYear: vi.fn(async () => {}),
|
||||
nextYear: vi.fn(async () => {}),
|
||||
prevMonth: vi.fn(() => undefined),
|
||||
nextMonth: vi.fn(() => undefined),
|
||||
toggleView: vi.fn(() => undefined),
|
||||
goBack: vi.fn(() => undefined),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.v = buildV();
|
||||
});
|
||||
|
||||
describe('MVacay', () => {
|
||||
it('FE-MOB-MVACSCR-001: shows only a spinner while the plan is loading', () => {
|
||||
mocks.v = buildV({ loading: true });
|
||||
const { container } = render(<MVacay />);
|
||||
|
||||
expect(container.querySelector('.animate-spin')).not.toBeNull();
|
||||
expect(screen.queryByText('2026')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSCR-002: renders the year header and wires its buttons', () => {
|
||||
render(<MVacay />);
|
||||
|
||||
expect(screen.getByText('2026')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Previous year' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next year' }));
|
||||
expect(mocks.v.prevYear).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.v.nextYear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSCR-003: the header icons open the invite, share and settings sheets', () => {
|
||||
render(<MVacay />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Invite User' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Shared Calendars' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Settings' }));
|
||||
|
||||
expect(mocks.v.setSheet).toHaveBeenNthCalledWith(1, 'invite');
|
||||
expect(mocks.v.setSheet).toHaveBeenNthCalledWith(2, 'share');
|
||||
expect(mocks.v.setSheet).toHaveBeenNthCalledWith(3, 'settings');
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSCR-004: passes the open sheet down to the matching child only', () => {
|
||||
mocks.v = buildV({ sheet: 'settings' });
|
||||
render(<MVacay />);
|
||||
|
||||
expect(screen.getByTestId('settings-sheet')).toHaveAttribute('data-open', 'true');
|
||||
expect(screen.getByTestId('invite-sheet')).toHaveAttribute('data-open', 'false');
|
||||
expect(screen.getByTestId('share-sheet')).toHaveAttribute('data-open', 'false');
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSCR-004b: each sheet closes back to no open sheet', () => {
|
||||
mocks.v = buildV({ sheet: 'invite' });
|
||||
render(<MVacay />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'close-invite' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'close-settings' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'close-share' }));
|
||||
expect(mocks.v.setSheet).toHaveBeenCalledTimes(3);
|
||||
expect(mocks.v.setSheet).toHaveBeenNthCalledWith(3, null);
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSCR-005: renders the person card with the remaining balance and stepper', () => {
|
||||
render(<MVacay />);
|
||||
|
||||
expect(screen.getByText('alice', { selector: 'div' })).toBeInTheDocument();
|
||||
const badge = screen.getByText('left');
|
||||
expect(badge).toHaveTextContent('26');
|
||||
expect(badge.style.color).toBe('rgb(59, 130, 246)');
|
||||
expect(screen.getByText('Days').previousElementSibling).toHaveTextContent('30');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Increase allowance' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Decrease allowance' }));
|
||||
expect(mocks.v.allowInc).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.v.allowDec).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSCR-006: renders a fractional balance with one decimal', () => {
|
||||
mocks.v = buildV({
|
||||
selectedStat: { user_id: 1, vacation_days: 30, used: 4.5, total_available: 30, remaining: 25.5, carried_over: 0 },
|
||||
});
|
||||
render(<MVacay />);
|
||||
|
||||
expect(screen.getByText('25.5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSCR-007: colors an overdrawn balance as a danger badge', () => {
|
||||
mocks.v = buildV({
|
||||
selectedStat: { user_id: 1, vacation_days: 30, used: 32, total_available: 30, remaining: -2, carried_over: 0 },
|
||||
});
|
||||
render(<MVacay />);
|
||||
|
||||
const badge = screen.getByText('-2').parentElement as HTMLElement;
|
||||
expect(badge.style.color).toBe('var(--m-st-danger)');
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSCR-008: an exhausted balance switches to the pending color', () => {
|
||||
mocks.v = buildV({
|
||||
selectedStat: { user_id: 1, vacation_days: 30, used: 30, total_available: 30, remaining: 0, carried_over: 0 },
|
||||
});
|
||||
render(<MVacay />);
|
||||
|
||||
const badge = screen.getByText('0').parentElement as HTMLElement;
|
||||
expect(badge.style.color).toBe('var(--m-st-pending)');
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSCR-009: hides the person card when no stat row exists', () => {
|
||||
mocks.v = buildV({ selectedStat: undefined });
|
||||
render(<MVacay />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Increase allowance' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSCR-010: person chips select their member and fall back on the shared color', () => {
|
||||
render(<MVacay />);
|
||||
|
||||
const bobChip = screen.getByRole('button', { name: 'bob' });
|
||||
const swatch = bobChip.querySelector('span') as HTMLElement;
|
||||
expect(swatch.style.background).toBe('rgb(99, 102, 241)');
|
||||
|
||||
fireEvent.click(bobChip);
|
||||
expect(mocks.v.selectPerson).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSCR-011: shared chips toggle their overlay and report their state', () => {
|
||||
mocks.v = buildV({
|
||||
incomingShares: [
|
||||
{ id: 7, owner_id: 3, username: 'carol', color: '#f59e0b', hidden: false },
|
||||
{ id: 8, owner_id: 4, username: 'dan', color: '#10b981', hidden: true },
|
||||
],
|
||||
});
|
||||
render(<MVacay />);
|
||||
|
||||
const carol = screen.getByRole('button', { name: 'carol' });
|
||||
const dan = screen.getByRole('button', { name: 'dan' });
|
||||
expect(carol).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(dan).toHaveAttribute('aria-pressed', 'false');
|
||||
|
||||
fireEvent.click(carol);
|
||||
fireEvent.click(dan);
|
||||
expect(mocks.v.toggleShareHidden).toHaveBeenNthCalledWith(1, 7, true);
|
||||
expect(mocks.v.toggleShareHidden).toHaveBeenNthCalledWith(2, 8, false);
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSCR-012: shows the company legend and every enabled holiday calendar', () => {
|
||||
mocks.v = buildV({
|
||||
holidaysEnabled: true,
|
||||
plan: {
|
||||
id: 1,
|
||||
holiday_calendars: [
|
||||
{ id: 1, region: 'DE', label: 'Germany', color: '#fecaca' },
|
||||
{ id: 2, region: 'NL', label: null, color: '#bbf7d0' },
|
||||
],
|
||||
},
|
||||
});
|
||||
render(<MVacay />);
|
||||
|
||||
expect(screen.getByText('Company days')).toBeInTheDocument();
|
||||
expect(screen.getByText('Germany')).toBeInTheDocument();
|
||||
expect(screen.getByText('NL')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSCR-013: the year grid shows twelve month cards and reports day taps', () => {
|
||||
render(<MVacay />);
|
||||
|
||||
MONTHS_SHORT.forEach(name => expect(screen.getByText(name)).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole('button', { name: '2026-06-15' }));
|
||||
expect(mocks.v.handleDayTap).toHaveBeenCalledWith('2026-06-15');
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSCR-014: edit view swaps in the month nav, weekday row and mode switch', () => {
|
||||
mocks.v = buildV({ view: 'edit' });
|
||||
render(<MVacay />);
|
||||
|
||||
expect(screen.getByText('June')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Mon')[0]).toBeInTheDocument();
|
||||
// Monday-first week: Sunday is the last weekday header.
|
||||
const headers = screen.getAllByText(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)$/);
|
||||
expect(headers[headers.length - 1]).toHaveTextContent('Sun');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Previous month' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next month' }));
|
||||
expect(mocks.v.prevMonth).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.v.nextMonth).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(screen.getByText('Mar'));
|
||||
expect(mocks.v.setMonthSlot).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSCR-015: a Sunday week start moves Sunday to the front of the weekday row', () => {
|
||||
mocks.v = buildV({ view: 'edit', weekStart: 0 });
|
||||
render(<MVacay />);
|
||||
|
||||
const headers = screen.getAllByText(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)$/);
|
||||
expect(headers[0]).toHaveTextContent('Sun');
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSCR-016: the mode switch drives person, company, comp and half-day logging', () => {
|
||||
mocks.v = buildV({ view: 'edit' });
|
||||
render(<MVacay />);
|
||||
|
||||
// The person chip row carries the same name; the mode switch comes last.
|
||||
const person = screen.getAllByRole('button', { name: 'alice' });
|
||||
fireEvent.click(person[person.length - 1]);
|
||||
expect(mocks.v.setMode).toHaveBeenLastCalledWith('vacation');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Company' }));
|
||||
expect(mocks.v.setMode).toHaveBeenLastCalledWith('company');
|
||||
|
||||
const comp = screen.getByRole('button', { name: 'Comp / Flex' });
|
||||
const half = screen.getByRole('button', { name: 'Half day' });
|
||||
expect(comp).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(half).toHaveAttribute('aria-pressed', 'false');
|
||||
|
||||
fireEvent.click(comp);
|
||||
fireEvent.click(half);
|
||||
expect(mocks.v.setCompDay).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.v.setHalfDay).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSCR-017: the company mode button disappears when company days are off', () => {
|
||||
mocks.v = buildV({ view: 'edit', companyHolidaysEnabled: false });
|
||||
render(<MVacay />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Company' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Company days')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSCR-018: the FAB flips between the year overview and the editor', () => {
|
||||
render(<MVacay />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit calendar' }));
|
||||
expect(mocks.v.toggleView).toHaveBeenCalledTimes(1);
|
||||
|
||||
mocks.v = buildV({ view: 'edit' });
|
||||
render(<MVacay />);
|
||||
expect(screen.getAllByRole('button', { name: 'View year' })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSCR-019: an incoming fusion request lists what fusing grants and both actions', () => {
|
||||
const acceptInvite = vi.fn((_planId: number) => undefined);
|
||||
const declineInvite = vi.fn((_planId: number) => undefined);
|
||||
mocks.v = buildV({
|
||||
incomingInvites: [{ plan_id: 42, owner_username: 'carol' }],
|
||||
acceptInvite,
|
||||
declineInvite,
|
||||
});
|
||||
render(<MVacay />);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: 'Fusion Request' });
|
||||
expect(within(dialog).getByText('carol')).toBeInTheDocument();
|
||||
expect(within(dialog).getByText('C')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Accept & Fuse' }));
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Decline' }));
|
||||
expect(acceptInvite).toHaveBeenCalledWith(42);
|
||||
expect(declineInvite).toHaveBeenCalledWith(42);
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSCR-020: no fusion request means no dialog', () => {
|
||||
render(<MVacay />);
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: 'Fusion Request' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
// FE-MOB-MVACINV-001 to FE-MOB-MVACINV-010
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { render, screen, fireEvent, waitFor, within } from '../../../helpers/render';
|
||||
import { server } from '../../../helpers/msw/server';
|
||||
import { resetAllStores } from '../../../helpers/store';
|
||||
import { useVacayStore } from '../../../../src/store/vacayStore';
|
||||
import MVacayInviteSheet from '../../../../src/mobile/screens/vacay/MVacayInviteSheet';
|
||||
|
||||
const toasts: { type: string; message: string }[] = [];
|
||||
|
||||
function availableUsers(users: { id: number; username: string; color: string | null }[]) {
|
||||
server.use(http.get('/api/addons/vacay/available-users', () => HttpResponse.json({ users })));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
toasts.length = 0;
|
||||
window.__addToast = ((message: string, type?: string) => {
|
||||
toasts.push({ type: type ?? 'info', message });
|
||||
return 1;
|
||||
}) as Window['__addToast'];
|
||||
availableUsers([
|
||||
{ id: 2, username: 'bob', color: '#ec4899' },
|
||||
{ id: 3, username: 'carol', color: '#f59e0b' },
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
function renderSheet(onClose = vi.fn(() => undefined)) {
|
||||
const view = render(<MVacayInviteSheet open onClose={onClose} />);
|
||||
return { ...view, onClose };
|
||||
}
|
||||
|
||||
describe('MVacayInviteSheet', () => {
|
||||
it('FE-MOB-MVACINV-001: renders nothing while closed', () => {
|
||||
render(<MVacayInviteSheet open={false} onClose={() => {}} />);
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: 'Invite User' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACINV-002: loads the invitable users and shows the picker placeholder', async () => {
|
||||
renderSheet();
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Select user' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Invite another TREK user to share a combined vacation calendar.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('No users available')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACINV-003: falls back to the empty state when the lookup fails', async () => {
|
||||
server.use(http.get('/api/addons/vacay/available-users', () => HttpResponse.json({ error: 'nope' }, { status: 500 })));
|
||||
renderSheet();
|
||||
|
||||
expect(await screen.findByText('No users available')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Select user' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACINV-004: the picker opens, selects a user and collapses again', async () => {
|
||||
renderSheet();
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Select user' }));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'carol' }));
|
||||
expect(await screen.findByRole('button', { name: 'carol' })).toBeInTheDocument();
|
||||
// Only the trigger keeps the name once the list collapsed.
|
||||
expect(screen.getAllByRole('button', { name: 'carol' })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACINV-005: the send button stays disabled until someone is picked', async () => {
|
||||
renderSheet();
|
||||
const send = await screen.findByRole('button', { name: 'Send Invite' });
|
||||
expect(send).toBeDisabled();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Select user' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'bob' }));
|
||||
expect(screen.getByRole('button', { name: 'Send Invite' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACINV-006: sending posts the picked user, toasts and closes', async () => {
|
||||
let invitedId: number | undefined;
|
||||
server.use(http.post('/api/addons/vacay/invite', async ({ request }) => {
|
||||
invitedId = ((await request.json()) as { user_id: number }).user_id;
|
||||
return HttpResponse.json({ success: true });
|
||||
}));
|
||||
const { onClose } = renderSheet();
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Select user' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'bob' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send Invite' }));
|
||||
|
||||
await waitFor(() => expect(invitedId).toBe(2));
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
|
||||
expect(toasts).toEqual([{ type: 'success', message: 'Invite sent' }]);
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACINV-007: a rejected invite surfaces the server error and keeps the sheet open', async () => {
|
||||
server.use(http.post('/api/addons/vacay/invite', () =>
|
||||
HttpResponse.json({ error: 'Already fused' }, { status: 409 })));
|
||||
const { onClose } = renderSheet();
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Select user' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'bob' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send Invite' }));
|
||||
|
||||
await waitFor(() => expect(toasts).toEqual([{ type: 'error', message: 'Already fused' }]));
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACINV-008: pending invites are listed and can be withdrawn', async () => {
|
||||
const cancelInvite = vi.fn(async (_userId: number) => {});
|
||||
useVacayStore.setState({
|
||||
pendingInvites: [{ user_id: 9, username: 'dan' }],
|
||||
cancelInvite,
|
||||
});
|
||||
renderSheet();
|
||||
|
||||
const row = (await screen.findByText('dan')).closest('div') as HTMLElement;
|
||||
expect(within(row).getByText('(pending)')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(row).getByRole('button', { name: 'Cancel' }));
|
||||
expect(cancelInvite).toHaveBeenCalledWith(9);
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACINV-009: the close icon and the cancel button both dismiss the sheet', async () => {
|
||||
const { onClose } = renderSheet();
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Close' }));
|
||||
const dialog = screen.getByRole('dialog', { name: 'Invite User' });
|
||||
fireEvent.click(within(dialog).getAllByRole('button', { name: 'Cancel' })[0]);
|
||||
expect(onClose).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACINV-010: reopening resets the previous selection', async () => {
|
||||
const { rerender } = render(<MVacayInviteSheet open onClose={() => {}} />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Select user' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'bob' }));
|
||||
expect(screen.getByRole('button', { name: 'bob' })).toBeInTheDocument();
|
||||
|
||||
rerender(<MVacayInviteSheet open={false} onClose={() => {}} />);
|
||||
rerender(<MVacayInviteSheet open onClose={() => {}} />);
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Select user' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
// FE-MOB-MVACMS-001 to FE-MOB-MVACMS-002
|
||||
import React from 'react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import MVacayMonth from '../../../../src/mobile/screens/vacay/MVacayMonth';
|
||||
import { personTint } from '../../../../src/mobile/screens/vacay/vacayDayModel';
|
||||
import type { DayVisualContext } from '../../../../src/mobile/screens/vacay/vacayDayModel';
|
||||
|
||||
/** jsdom rewrites hsl() to rgb() on assignment — compare through the same path. */
|
||||
function asRendered(css: string): string {
|
||||
const probe = document.createElement('div');
|
||||
probe.style.background = css;
|
||||
return probe.style.background;
|
||||
}
|
||||
|
||||
const base: DayVisualContext = {
|
||||
todayStr: '2099-01-01',
|
||||
entryMap: {},
|
||||
companyHolidaySet: new Set(),
|
||||
companyHolidaysEnabled: true,
|
||||
holidays: {},
|
||||
weekendDays: [0, 6],
|
||||
};
|
||||
|
||||
function renderMonth(ctx: DayVisualContext) {
|
||||
return render(
|
||||
<MVacayMonth
|
||||
year={2026}
|
||||
month={5}
|
||||
variant="full"
|
||||
weekStart={1}
|
||||
ctx={ctx}
|
||||
tripDates={new Set()}
|
||||
tripDotColor="#3b82f6"
|
||||
onDayTap={() => {}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('MVacayMonth segments', () => {
|
||||
it('FE-MOB-MVACMS-001: several people share the cell as equal-width segments', () => {
|
||||
renderMonth({
|
||||
...base,
|
||||
entryMap: {
|
||||
'2026-06-15': [
|
||||
{ date: '2026-06-15', user_id: 1, person_color: '#3b82f6' },
|
||||
{ date: '2026-06-15', user_id: 2, person_color: '#ec4899', kind: 'comp' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const cell = screen.getByRole('button', { name: '2026-06-15' });
|
||||
const segments = [...cell.querySelectorAll<HTMLElement>('.absolute.top-0')];
|
||||
|
||||
expect(segments).toHaveLength(2);
|
||||
expect(segments[0].style.width).toBe('50%');
|
||||
expect(segments[1].style.left).toBe('50%');
|
||||
// The vacation segment is a solid tint, the comp one is hatched.
|
||||
expect(segments[0].style.background).toBe(asRendered(personTint('#3b82f6')));
|
||||
expect(segments[1].style.background).toContain('repeating-linear-gradient(45deg');
|
||||
// With overlays the cell itself stays transparent underneath them.
|
||||
expect(cell.style.background).toBe('transparent');
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACMS-002: a single person fills the cell without overlays', () => {
|
||||
renderMonth({
|
||||
...base,
|
||||
entryMap: { '2026-06-15': [{ date: '2026-06-15', user_id: 1, person_color: '#3b82f6' }] },
|
||||
});
|
||||
|
||||
const cell = screen.getByRole('button', { name: '2026-06-15' });
|
||||
expect(cell.querySelectorAll('.absolute.top-0')).toHaveLength(0);
|
||||
expect(cell.style.background).toBe(asRendered(personTint('#3b82f6')));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,514 @@
|
||||
// FE-MOB-MVACSET-001 to FE-MOB-MVACSET-024
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { render, screen, fireEvent, waitFor, within } from '../../../helpers/render';
|
||||
import { server } from '../../../helpers/msw/server';
|
||||
import { resetAllStores } from '../../../helpers/store';
|
||||
import { useVacayStore } from '../../../../src/store/vacayStore';
|
||||
import MVacaySettingsSheet from '../../../../src/mobile/screens/vacay/MVacaySettingsSheet';
|
||||
import type { VacayHolidayCalendar, VacayPlan } from '../../../../src/types';
|
||||
|
||||
const toasts: { type: string; message: string }[] = [];
|
||||
|
||||
function buildPlan(over: Partial<VacayPlan> = {}): VacayPlan {
|
||||
return {
|
||||
id: 1,
|
||||
holidays_enabled: false,
|
||||
school_holidays_enabled: false,
|
||||
holidays_region: null,
|
||||
holiday_calendars: [],
|
||||
block_weekends: true,
|
||||
carry_over_enabled: false,
|
||||
company_holidays_enabled: false,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
/** The countries endpoint answers with nager's `{ countryCode, name }` rows. */
|
||||
function countriesRespond(rows: { countryCode: string; name: string }[]) {
|
||||
server.use(http.get('/api/addons/vacay/holidays/countries', () => HttpResponse.json(rows)));
|
||||
}
|
||||
|
||||
/** Regional public holidays — `counties` is what turns the region picker on. */
|
||||
function regionalHolidays() {
|
||||
server.use(http.get('/api/addons/vacay/holidays/:year/:country', () => HttpResponse.json([
|
||||
{ date: '2026-11-01', name: 'All Saints', localName: 'Allerheiligen', global: false, counties: ['DE-BY', 'DE-NW'] },
|
||||
])));
|
||||
}
|
||||
|
||||
function seedPlan(plan: VacayPlan, extra: Record<string, unknown> = {}) {
|
||||
useVacayStore.setState({ plan, ...extra });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
toasts.length = 0;
|
||||
window.__addToast = ((message: string, type?: string) => {
|
||||
toasts.push({ type: type ?? 'info', message });
|
||||
return 1;
|
||||
}) as Window['__addToast'];
|
||||
countriesRespond([
|
||||
{ countryCode: 'NL', name: 'Netherlands' },
|
||||
{ countryCode: 'DE', name: 'Germany' },
|
||||
]);
|
||||
seedPlan(buildPlan());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
function renderSheet(onClose = vi.fn(() => undefined)) {
|
||||
const view = render(<MVacaySettingsSheet open onClose={onClose} />);
|
||||
return { ...view, onClose };
|
||||
}
|
||||
|
||||
/** Country picker options only appear once the countries request resolved. */
|
||||
async function waitForCountries() {
|
||||
await waitFor(() => expect(screen.getAllByRole('option', { name: 'Germany' }).length).toBeGreaterThan(0));
|
||||
}
|
||||
|
||||
describe('MVacaySettingsSheet', () => {
|
||||
it('FE-MOB-MVACSET-001: renders nothing without a plan', () => {
|
||||
useVacayStore.setState({ plan: null });
|
||||
render(<MVacaySettingsSheet open onClose={() => {}} />);
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: 'Settings' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSET-002: renders nothing while closed', () => {
|
||||
render(<MVacaySettingsSheet open={false} onClose={() => {}} />);
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: 'Settings' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSET-003: the close icon dismisses the sheet', () => {
|
||||
const { onClose } = renderSheet();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSET-004: shows the weekend-day chips only while weekends are blocked', () => {
|
||||
renderSheet();
|
||||
|
||||
expect(screen.getByText('Weekend days')).toBeInTheDocument();
|
||||
const sat = screen.getByRole('button', { name: 'Sat' });
|
||||
expect(sat.className).toContain('bg-m-act');
|
||||
expect(screen.getByRole('button', { name: 'Wed' }).className).not.toContain('bg-m-act');
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSET-005: toggling block-weekends writes the inverted flag', () => {
|
||||
const updatePlan = vi.fn(async (_updates: Partial<VacayPlan>) => {});
|
||||
seedPlan(buildPlan(), { updatePlan });
|
||||
renderSheet();
|
||||
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Block Weekends' }));
|
||||
expect(updatePlan).toHaveBeenCalledWith({ block_weekends: false });
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSET-006: an unblocked plan hides the weekend-day chips', () => {
|
||||
seedPlan(buildPlan({ block_weekends: false }));
|
||||
renderSheet();
|
||||
|
||||
expect(screen.queryByText('Weekend days')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSET-007: weekend chips add and remove their day from the plan', () => {
|
||||
const updatePlan = vi.fn(async (_updates: Partial<VacayPlan>) => {});
|
||||
seedPlan(buildPlan({ weekend_days: '0,6' }), { updatePlan });
|
||||
renderSheet();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Fri' }));
|
||||
expect(updatePlan).toHaveBeenLastCalledWith({ weekend_days: '0,6,5' });
|
||||
|
||||
// 'Sun' is also a week-start button; the weekend chips come first in the DOM.
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Sun' })[0]);
|
||||
expect(updatePlan).toHaveBeenLastCalledWith({ weekend_days: '6' });
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSET-008: the week-start buttons mark the active day and save the other', () => {
|
||||
const updatePlan = vi.fn(async (_updates: Partial<VacayPlan>) => {});
|
||||
seedPlan(buildPlan({ block_weekends: false }), { updatePlan });
|
||||
renderSheet();
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Mon' }).className).toContain('bg-m-act');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sun' }));
|
||||
expect(updatePlan).toHaveBeenCalledWith({ week_start: 0 });
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSET-009: carry-over and company holidays toggle their plan flags', () => {
|
||||
const updatePlan = vi.fn(async (_updates: Partial<VacayPlan>) => {});
|
||||
seedPlan(buildPlan({ carry_over_enabled: true }), { updatePlan });
|
||||
renderSheet();
|
||||
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Carry Over' }));
|
||||
expect(updatePlan).toHaveBeenLastCalledWith({ carry_over_enabled: false });
|
||||
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Company Holidays' }));
|
||||
expect(updatePlan).toHaveBeenLastCalledWith({ company_holidays_enabled: true });
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSET-010: the calendar leave year shows its Jan–Dec window', () => {
|
||||
useVacayStore.setState({ selectedYear: 2026 });
|
||||
renderSheet();
|
||||
|
||||
expect(screen.getByText('2026: Jan 2026 – Dec 2026')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Calendar' }).className).toContain('bg-m-act');
|
||||
expect(screen.queryByRole('combobox', { name: 'Start month' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Hire date')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSET-011: switching to a fiscal year saves the type and reveals month/day', () => {
|
||||
const updateYearSettings = vi.fn(async () => {});
|
||||
useVacayStore.setState({
|
||||
selectedYear: 2026,
|
||||
updateYearSettings,
|
||||
yearSettings: { year_type: 'fiscal', year_start_month: 7, year_start_day: 1, hire_date: null },
|
||||
});
|
||||
renderSheet();
|
||||
|
||||
expect(screen.getByText('2026: Jul 2026 – Jun 2027')).toBeInTheDocument();
|
||||
expect(screen.getByRole('combobox', { name: 'Start month' })).toHaveValue('7');
|
||||
expect(screen.getByRole('combobox', { name: 'Day' })).toHaveValue('1');
|
||||
|
||||
fireEvent.change(screen.getByRole('combobox', { name: 'Start month' }), { target: { value: '4' } });
|
||||
expect(updateYearSettings).toHaveBeenLastCalledWith({
|
||||
year_type: 'fiscal', year_start_month: 4, year_start_day: 1, hire_date: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSET-012: a start day past the new month cap is clamped on save', () => {
|
||||
const updateYearSettings = vi.fn(async () => {});
|
||||
useVacayStore.setState({
|
||||
updateYearSettings,
|
||||
yearSettings: { year_type: 'fiscal', year_start_month: 1, year_start_day: 31, hire_date: null },
|
||||
});
|
||||
renderSheet();
|
||||
|
||||
// February only ever has 28 guaranteed days.
|
||||
fireEvent.change(screen.getByRole('combobox', { name: 'Start month' }), { target: { value: '2' } });
|
||||
expect(updateYearSettings).toHaveBeenLastCalledWith({
|
||||
year_type: 'fiscal', year_start_month: 2, year_start_day: 28, hire_date: null,
|
||||
});
|
||||
expect(screen.getAllByRole('option', { name: '31' })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSET-013: the day select saves the picked day', () => {
|
||||
const updateYearSettings = vi.fn(async () => {});
|
||||
useVacayStore.setState({
|
||||
updateYearSettings,
|
||||
yearSettings: { year_type: 'fiscal', year_start_month: 4, year_start_day: 1, hire_date: null },
|
||||
});
|
||||
renderSheet();
|
||||
|
||||
fireEvent.change(screen.getByRole('combobox', { name: 'Day' }), { target: { value: '15' } });
|
||||
expect(updateYearSettings).toHaveBeenLastCalledWith({
|
||||
year_type: 'fiscal', year_start_month: 4, year_start_day: 15, hire_date: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSET-014: the anniversary year is driven by the hire date field', () => {
|
||||
const updateYearSettings = vi.fn(async () => {});
|
||||
useVacayStore.setState({
|
||||
updateYearSettings,
|
||||
yearSettings: { year_type: 'anniversary', year_start_month: 1, year_start_day: 1, hire_date: '2020-09-14' },
|
||||
});
|
||||
renderSheet();
|
||||
|
||||
const hire = screen.getByLabelText('Hire date');
|
||||
expect(hire).toHaveValue('2020-09-14');
|
||||
|
||||
fireEvent.change(hire, { target: { value: '2021-03-02' } });
|
||||
expect(updateYearSettings).toHaveBeenLastCalledWith({
|
||||
year_type: 'anniversary', year_start_month: 1, year_start_day: 1, hire_date: '2021-03-02',
|
||||
});
|
||||
|
||||
fireEvent.change(hire, { target: { value: '' } });
|
||||
expect(updateYearSettings).toHaveBeenLastCalledWith({
|
||||
year_type: 'anniversary', year_start_month: 1, year_start_day: 1, hire_date: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSET-015: picking a year type saves it', () => {
|
||||
const updateYearSettings = vi.fn(async () => {});
|
||||
useVacayStore.setState({ updateYearSettings });
|
||||
renderSheet();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Hire date' }));
|
||||
expect(updateYearSettings).toHaveBeenCalledWith({
|
||||
year_type: 'anniversary', year_start_month: 1, year_start_day: 1, hire_date: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSET-016: public-holiday calendars stay hidden until the toggle is on', () => {
|
||||
const updatePlan = vi.fn(async (_updates: Partial<VacayPlan>) => {});
|
||||
seedPlan(buildPlan(), { updatePlan });
|
||||
renderSheet();
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Add calendar' })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Public Holidays' }));
|
||||
expect(updatePlan).toHaveBeenCalledWith({ holidays_enabled: true });
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSET-017: an enabled plan lists its public calendars and the add button', async () => {
|
||||
const calendars: VacayHolidayCalendar[] = [
|
||||
{ id: 1, plan_id: 1, region: 'DE', label: 'Germany', color: '#fecaca', sort_order: 0 },
|
||||
{ id: 2, plan_id: 1, region: 'NL|group:NL-NO', label: 'Noord', color: '#bbf7d0', sort_order: 1, type: 'school_holiday' },
|
||||
];
|
||||
seedPlan(buildPlan({ holidays_enabled: true, holiday_calendars: calendars }));
|
||||
renderSheet();
|
||||
await waitForCountries();
|
||||
|
||||
// Only the public calendar renders under the public-holiday section.
|
||||
const labels = screen.getAllByPlaceholderText('Label (optional)');
|
||||
expect(labels).toHaveLength(1);
|
||||
expect(labels[0]).toHaveValue('Germany');
|
||||
expect(screen.getAllByRole('button', { name: 'Add calendar' })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSET-018: editing a calendar label writes it back on blur only when it changed', async () => {
|
||||
const updateHolidayCalendar = vi.fn(async (_id: number, _data: Record<string, unknown>) => {});
|
||||
seedPlan(
|
||||
buildPlan({
|
||||
holidays_enabled: true,
|
||||
holiday_calendars: [{ id: 3, plan_id: 1, region: 'DE', label: 'Germany', color: '#fecaca', sort_order: 0 }],
|
||||
}),
|
||||
{ updateHolidayCalendar },
|
||||
);
|
||||
renderSheet();
|
||||
|
||||
const input = screen.getByDisplayValue('Germany');
|
||||
fireEvent.blur(input);
|
||||
expect(updateHolidayCalendar).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.change(input, { target: { value: ' Deutschland ' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
fireEvent.blur(input);
|
||||
expect(updateHolidayCalendar).toHaveBeenCalledWith(3, { label: 'Deutschland' });
|
||||
|
||||
fireEvent.change(input, { target: { value: ' ' } });
|
||||
fireEvent.blur(input);
|
||||
expect(updateHolidayCalendar).toHaveBeenLastCalledWith(3, { label: null });
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSET-019: the swatch palette recolors a calendar and closes again', () => {
|
||||
const updateHolidayCalendar = vi.fn(async (_id: number, _data: Record<string, unknown>) => {});
|
||||
seedPlan(
|
||||
buildPlan({
|
||||
holidays_enabled: true,
|
||||
holiday_calendars: [{ id: 3, plan_id: 1, region: 'DE', label: null, color: '#fecaca', sort_order: 0 }],
|
||||
}),
|
||||
{ updateHolidayCalendar },
|
||||
);
|
||||
renderSheet();
|
||||
|
||||
// The sheet renders through a portal, so the palette lives outside `container`.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Color' }));
|
||||
const swatches = document.querySelectorAll('.rounded-md');
|
||||
expect(swatches).toHaveLength(8);
|
||||
|
||||
// Re-picking the current color is a no-op; a different one saves.
|
||||
fireEvent.click(swatches[0]);
|
||||
expect(updateHolidayCalendar).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Color' }));
|
||||
fireEvent.click(document.querySelectorAll('.rounded-md')[3]);
|
||||
expect(updateHolidayCalendar).toHaveBeenCalledWith(3, { color: '#bbf7d0' });
|
||||
expect(document.querySelectorAll('.rounded-md')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSET-020: a calendar can change country and, once regional, its region', async () => {
|
||||
regionalHolidays();
|
||||
const updateHolidayCalendar = vi.fn(async (_id: number, _data: Record<string, unknown>) => {});
|
||||
seedPlan(
|
||||
buildPlan({
|
||||
holidays_enabled: true,
|
||||
holiday_calendars: [{ id: 3, plan_id: 1, region: 'DE', label: null, color: '#fecaca', sort_order: 0 }],
|
||||
}),
|
||||
{ updateHolidayCalendar },
|
||||
);
|
||||
renderSheet();
|
||||
await waitForCountries();
|
||||
|
||||
fireEvent.change(screen.getByRole('combobox', { name: 'Select country' }), { target: { value: 'NL' } });
|
||||
expect(updateHolidayCalendar).toHaveBeenCalledWith(3, { region: 'NL' });
|
||||
|
||||
const region = await screen.findByRole('combobox', { name: 'Select region (optional)' });
|
||||
fireEvent.change(region, { target: { value: 'DE-BY' } });
|
||||
expect(updateHolidayCalendar).toHaveBeenLastCalledWith(3, { region: 'DE-BY' });
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSET-021: a calendar can be deleted', () => {
|
||||
const deleteHolidayCalendar = vi.fn(async (_id: number) => {});
|
||||
seedPlan(
|
||||
buildPlan({
|
||||
holidays_enabled: true,
|
||||
holiday_calendars: [{ id: 3, plan_id: 1, region: 'DE', label: null, color: '#fecaca', sort_order: 0 }],
|
||||
}),
|
||||
{ deleteHolidayCalendar },
|
||||
);
|
||||
renderSheet();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
expect(deleteHolidayCalendar).toHaveBeenCalledWith(3);
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSET-022: the add-calendar draft opens, cancels and posts a public calendar', async () => {
|
||||
const addHolidayCalendar = vi.fn(async (_data: Record<string, unknown>) => {});
|
||||
seedPlan(buildPlan({ holidays_enabled: true }), { addHolidayCalendar });
|
||||
renderSheet();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add calendar' }));
|
||||
expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(screen.getByRole('button', { name: 'Add calendar' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add calendar' }));
|
||||
await waitForCountries();
|
||||
fireEvent.change(screen.getByPlaceholderText('Label (optional)'), { target: { value: ' Feiertage ' } });
|
||||
fireEvent.change(screen.getByRole('combobox', { name: 'Select country' }), { target: { value: 'DE' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }));
|
||||
|
||||
await waitFor(() => expect(addHolidayCalendar).toHaveBeenCalledWith({
|
||||
region: 'DE', color: '#fecaca', label: 'Feiertage', type: 'public_holiday',
|
||||
}));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Add calendar' })).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSET-023: a regional country blocks the draft until a region is picked', async () => {
|
||||
regionalHolidays();
|
||||
const addHolidayCalendar = vi.fn(async (_data: Record<string, unknown>) => {});
|
||||
seedPlan(buildPlan({ holidays_enabled: true }), { addHolidayCalendar });
|
||||
renderSheet();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add calendar' }));
|
||||
await waitForCountries();
|
||||
fireEvent.change(screen.getByRole('combobox', { name: 'Select country' }), { target: { value: 'DE' } });
|
||||
|
||||
const region = await screen.findByRole('combobox', { name: 'Select region (optional)' });
|
||||
expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled();
|
||||
|
||||
fireEvent.change(region, { target: { value: 'DE-NW' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }));
|
||||
await waitFor(() => expect(addHolidayCalendar).toHaveBeenCalledWith({
|
||||
region: 'DE-NW', color: '#fecaca', label: null, type: 'public_holiday',
|
||||
}));
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSET-024: the school-holiday section only offers supported countries', async () => {
|
||||
const addHolidayCalendar = vi.fn(async (_data: Record<string, unknown>) => {});
|
||||
countriesRespond([
|
||||
{ countryCode: 'NL', name: 'Netherlands' },
|
||||
{ countryCode: 'US', name: 'United States' },
|
||||
]);
|
||||
seedPlan(buildPlan({ school_holidays_enabled: true }), { addHolidayCalendar });
|
||||
renderSheet();
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Add calendar' }));
|
||||
const country = screen.getByRole('combobox', { name: 'Select country' });
|
||||
await waitFor(() => expect(within(country).getByRole('option', { name: 'Netherlands' })).toBeInTheDocument());
|
||||
expect(within(country).queryByRole('option', { name: 'United States' })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(country, { target: { value: 'NL' } });
|
||||
const region = await screen.findByRole('combobox', { name: 'Select region (optional)' });
|
||||
fireEvent.change(region, { target: { value: 'NL|group:NL-NO' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }));
|
||||
|
||||
await waitFor(() => expect(addHolidayCalendar).toHaveBeenCalledWith({
|
||||
region: 'NL|group:NL-NO', color: '#fecaca', label: null, type: 'school_holiday',
|
||||
}));
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSET-024b: an existing school calendar is editable with its subdivision regions', async () => {
|
||||
server.use(http.get('/api/addons/vacay/school-holidays/regions/:country', () => HttpResponse.json({
|
||||
groups: [],
|
||||
subdivisions: [
|
||||
{ code: 'DE-BY', shortName: 'BY', name: [{ language: 'EN', text: 'Bavaria' }] },
|
||||
{ code: 'DE-NW', shortName: 'NW', name: [{ language: 'EN', text: 'North Rhine-Westphalia' }] },
|
||||
],
|
||||
})));
|
||||
const updateHolidayCalendar = vi.fn(async (_id: number, _data: Record<string, unknown>) => {});
|
||||
const deleteHolidayCalendar = vi.fn(async (_id: number) => {});
|
||||
seedPlan(
|
||||
buildPlan({
|
||||
school_holidays_enabled: true,
|
||||
holiday_calendars: [
|
||||
{ id: 5, plan_id: 1, region: 'DE-BY', label: 'Bayern', color: '#bbf7d0', sort_order: 0, type: 'school_holiday' },
|
||||
// A calendar that never got a country has nothing to look regions up with.
|
||||
{ id: 6, plan_id: 1, region: '', label: null, color: '#fde68a', sort_order: 1, type: 'school_holiday' },
|
||||
],
|
||||
}),
|
||||
{ updateHolidayCalendar, deleteHolidayCalendar },
|
||||
);
|
||||
renderSheet();
|
||||
|
||||
expect(screen.getAllByPlaceholderText('Label (optional)')).toHaveLength(2);
|
||||
const region = await screen.findByRole('combobox', { name: 'Select region (optional)' });
|
||||
fireEvent.change(region, { target: { value: 'DE-NW' } });
|
||||
expect(updateHolidayCalendar).toHaveBeenCalledWith(5, { region: 'DE-NW' });
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Delete' })[1]);
|
||||
expect(deleteHolidayCalendar).toHaveBeenCalledWith(6);
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSET-024c: the school draft can be cancelled again', () => {
|
||||
seedPlan(buildPlan({ school_holidays_enabled: true }));
|
||||
renderSheet();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add calendar' }));
|
||||
expect(screen.getByRole('button', { name: 'Add' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(screen.queryByRole('button', { name: 'Add' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Add calendar' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSET-025: the school-holiday toggle writes its own plan flag', () => {
|
||||
const updatePlan = vi.fn(async (_updates: Partial<VacayPlan>) => {});
|
||||
seedPlan(buildPlan(), { updatePlan });
|
||||
renderSheet();
|
||||
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'School Holidays' }));
|
||||
expect(updatePlan).toHaveBeenCalledWith({ school_holidays_enabled: true });
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSET-026: the dissolve block appears only for a fused plan', () => {
|
||||
renderSheet();
|
||||
expect(screen.queryByText('Dissolve Fusion')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSET-027: dissolving lists the members, toasts and closes the sheet', async () => {
|
||||
const dissolve = vi.fn(async () => {});
|
||||
seedPlan(buildPlan(), {
|
||||
isFused: true,
|
||||
dissolve,
|
||||
users: [{ id: 1, username: 'alice', color: '#3b82f6' }, { id: 2, username: 'bob', color: null }],
|
||||
});
|
||||
const { onClose } = renderSheet();
|
||||
|
||||
expect(screen.getByText('Dissolve Fusion')).toBeInTheDocument();
|
||||
expect(screen.getByText('alice')).toBeInTheDocument();
|
||||
expect(screen.getByText('bob')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Dissolve' }));
|
||||
await waitFor(() => expect(dissolve).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
|
||||
expect(toasts).toEqual([{ type: 'success', message: 'Calendar separated' }]);
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSET-028: a failing countries request leaves the picker empty', async () => {
|
||||
server.use(http.get('/api/addons/vacay/holidays/countries', () =>
|
||||
HttpResponse.json({ error: 'down' }, { status: 500 })));
|
||||
seedPlan(buildPlan({ holidays_enabled: true }));
|
||||
renderSheet();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add calendar' }));
|
||||
const country = screen.getByRole('combobox', { name: 'Select country' });
|
||||
await waitFor(() => expect(within(country).getAllByRole('option')).toHaveLength(1));
|
||||
// Only the disabled placeholder option survives — the label falls back to it too.
|
||||
expect(screen.getAllByText('Select country')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
// FE-MOB-MVACSHR-001 to FE-MOB-MVACSHR-011
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { render, screen, fireEvent, waitFor, within } from '../../../helpers/render';
|
||||
import { server } from '../../../helpers/msw/server';
|
||||
import { resetAllStores } from '../../../helpers/store';
|
||||
import { useVacayStore } from '../../../../src/store/vacayStore';
|
||||
import MVacayShareSheet from '../../../../src/mobile/screens/vacay/MVacayShareSheet';
|
||||
|
||||
const toasts: { type: string; message: string }[] = [];
|
||||
|
||||
function shareCandidates(users: { id: number; username: string }[]) {
|
||||
server.use(http.get('/api/addons/vacay/shares/available-users', () => HttpResponse.json({ users })));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
toasts.length = 0;
|
||||
window.__addToast = ((message: string, type?: string) => {
|
||||
toasts.push({ type: type ?? 'info', message });
|
||||
return 1;
|
||||
}) as Window['__addToast'];
|
||||
shareCandidates([{ id: 2, username: 'bob' }, { id: 3, username: 'carol' }]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
function renderSheet(onClose = vi.fn(() => undefined)) {
|
||||
const view = render(<MVacayShareSheet open onClose={onClose} />);
|
||||
return { ...view, onClose };
|
||||
}
|
||||
|
||||
describe('MVacayShareSheet', () => {
|
||||
it('FE-MOB-MVACSHR-001: renders nothing while closed', () => {
|
||||
render(<MVacayShareSheet open={false} onClose={() => {}} />);
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: 'Shared Calendars' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSHR-002: loads the share candidates and explains what sharing does', async () => {
|
||||
renderSheet();
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Select user' })).toBeInTheDocument();
|
||||
expect(screen.getByText(/View only — no merge, no editing\./)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSHR-003: shows the empty state when nobody is left to share with', async () => {
|
||||
shareCandidates([]);
|
||||
renderSheet();
|
||||
|
||||
expect(await screen.findByText('No users available')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Share' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSHR-004: keeps the empty state when the lookup fails', async () => {
|
||||
server.use(http.get('/api/addons/vacay/shares/available-users', () =>
|
||||
HttpResponse.json({ error: 'nope' }, { status: 500 })));
|
||||
renderSheet();
|
||||
|
||||
expect(await screen.findByText('No users available')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSHR-005: the share button unlocks once a user is picked', async () => {
|
||||
renderSheet();
|
||||
expect(await screen.findByRole('button', { name: 'Share' })).toBeDisabled();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Select user' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'carol' }));
|
||||
expect(screen.getByRole('button', { name: 'Share' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSHR-006: sharing posts the user, toasts and clears the selection', async () => {
|
||||
let sharedWith: number | undefined;
|
||||
server.use(http.post('/api/addons/vacay/shares', async ({ request }) => {
|
||||
sharedWith = ((await request.json()) as { user_id: number }).user_id;
|
||||
return HttpResponse.json({ success: true });
|
||||
}));
|
||||
renderSheet();
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Select user' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'carol' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Share' }));
|
||||
|
||||
await waitFor(() => expect(sharedWith).toBe(3));
|
||||
await waitFor(() => expect(toasts).toEqual([{ type: 'success', message: 'Calendar shared' }]));
|
||||
expect(await screen.findByRole('button', { name: 'Select user' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSHR-007: a rejected share surfaces the server error', async () => {
|
||||
server.use(http.post('/api/addons/vacay/shares', () =>
|
||||
HttpResponse.json({ error: 'Already shared' }, { status: 409 })));
|
||||
renderSheet();
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Select user' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'bob' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Share' }));
|
||||
|
||||
await waitFor(() => expect(toasts).toEqual([{ type: 'error', message: 'Already shared' }]));
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSHR-008: incoming shares toggle their overlay through the eye button', async () => {
|
||||
const setShareHidden = vi.fn(async (_shareId: number, _hidden: boolean) => {});
|
||||
useVacayStore.setState({
|
||||
incomingShares: [
|
||||
{ id: 5, owner_id: 3, username: 'carol', color: '#f59e0b', hidden: false },
|
||||
{ id: 6, owner_id: 4, username: 'dan', color: '#10b981', hidden: true },
|
||||
],
|
||||
setShareHidden,
|
||||
});
|
||||
renderSheet();
|
||||
|
||||
expect(await screen.findByText('Shared with you')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Hide from calendar' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show in calendar' }));
|
||||
|
||||
expect(setShareHidden).toHaveBeenNthCalledWith(1, 5, true);
|
||||
expect(setShareHidden).toHaveBeenNthCalledWith(2, 6, false);
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSHR-009: a failing hide toggle reports the error', async () => {
|
||||
const setShareHidden = vi.fn(async (_shareId: number, _hidden: boolean) => {
|
||||
throw { response: { data: { error: 'Share vanished' } }, message: 'nope' };
|
||||
});
|
||||
useVacayStore.setState({
|
||||
incomingShares: [{ id: 5, owner_id: 3, username: 'carol', color: '#f59e0b', hidden: false }],
|
||||
setShareHidden,
|
||||
});
|
||||
renderSheet();
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Hide from calendar' }));
|
||||
await waitFor(() => expect(toasts).toEqual([{ type: 'error', message: 'Share vanished' }]));
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSHR-010: both share directions can be revoked', async () => {
|
||||
const removeShare = vi.fn(async (_shareId: number) => {});
|
||||
useVacayStore.setState({
|
||||
incomingShares: [{ id: 5, owner_id: 3, username: 'carol', color: '#f59e0b', hidden: false }],
|
||||
outgoingShares: [{ id: 11, user_id: 8, username: 'erin' }],
|
||||
removeShare,
|
||||
});
|
||||
renderSheet();
|
||||
|
||||
expect(await screen.findByText('You share with')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Stop sharing' }));
|
||||
|
||||
expect(removeShare).toHaveBeenNthCalledWith(1, 5);
|
||||
expect(removeShare).toHaveBeenNthCalledWith(2, 11);
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSHR-011: a failing revoke reports the fallback message', async () => {
|
||||
// No `response` and not an Error — getApiErrorMessage falls through to the label.
|
||||
const removeShare = vi.fn(async (_shareId: number) => { throw { status: 500 }; });
|
||||
useVacayStore.setState({
|
||||
outgoingShares: [{ id: 11, user_id: 8, username: 'erin' }],
|
||||
removeShare,
|
||||
});
|
||||
renderSheet();
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Stop sharing' }));
|
||||
await waitFor(() => expect(toasts).toEqual([{ type: 'error', message: 'Could not share calendar' }]));
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACSHR-012: the close icon dismisses the sheet', async () => {
|
||||
const { onClose } = renderSheet();
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Shared Calendars' });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Close' }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,427 @@
|
||||
// FE-MOB-MVAC-001 to FE-MOB-MVAC-025
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../helpers/msw/server';
|
||||
import { resetAllStores } from '../../../helpers/store';
|
||||
import { useMVacay } from '../../../../src/mobile/screens/vacay/useMVacay';
|
||||
import { useVacayStore } from '../../../../src/store/vacayStore';
|
||||
import { useAuthStore } from '../../../../src/store/authStore';
|
||||
import type { VacayEntry, VacayPlan, VacayStat, VacayUser } from '../../../../src/types';
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
|
||||
vi.mock('react-router-dom', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('react-router-dom')>();
|
||||
return { ...actual, useNavigate: () => navigateMock };
|
||||
});
|
||||
|
||||
const mocks = vi.hoisted(() => ({ vacay: {} as Record<string, unknown> }));
|
||||
|
||||
vi.mock('../../../../src/pages/vacay/useVacay', () => ({
|
||||
useVacay: () => mocks.vacay,
|
||||
}));
|
||||
|
||||
const toasts: { type: string; message: string }[] = [];
|
||||
|
||||
const alice: VacayUser = { id: 1, username: 'alice', color: '#3b82f6' };
|
||||
const bob: VacayUser = { id: 2, username: 'bob', color: '#ec4899' };
|
||||
|
||||
function buildPlan(over: Partial<VacayPlan> = {}): VacayPlan {
|
||||
return {
|
||||
id: 1,
|
||||
holidays_enabled: false,
|
||||
holidays_region: null,
|
||||
holiday_calendars: [],
|
||||
block_weekends: true,
|
||||
carry_over_enabled: false,
|
||||
company_holidays_enabled: true,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function buildStat(over: Partial<VacayStat> = {}): VacayStat {
|
||||
return {
|
||||
user_id: 1, person_name: 'alice', person_color: '#3b82f6', year: 2026,
|
||||
vacation_days: 30, carried_over: 0, total_available: 30, used: 4, remaining: 26,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function buildVacay(over: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
years: [2025, 2026],
|
||||
selectedYear: 2026,
|
||||
setSelectedYear: vi.fn((_year: number) => undefined),
|
||||
loading: false,
|
||||
incomingInvites: [],
|
||||
acceptInvite: vi.fn(async (_planId: number) => {}),
|
||||
declineInvite: vi.fn(async (_planId: number) => {}),
|
||||
plan: buildPlan(),
|
||||
handleAddNextYear: vi.fn(() => undefined),
|
||||
handleAddPrevYear: vi.fn(() => undefined),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function tripsRespond(trips: { start_date?: string | null; end_date?: string | null }[]) {
|
||||
server.use(http.get('/api/trips', () => HttpResponse.json({ trips })));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
navigateMock.mockClear();
|
||||
toasts.length = 0;
|
||||
window.__addToast = ((message: string, type?: string) => {
|
||||
toasts.push({ type: type ?? 'info', message });
|
||||
return 1;
|
||||
}) as Window['__addToast'];
|
||||
mocks.vacay = buildVacay();
|
||||
tripsRespond([]);
|
||||
useAuthStore.setState({ user: { id: 1, username: 'alice', email: 'a@t.app', role: 'user' } as never });
|
||||
useVacayStore.setState({
|
||||
users: [alice, bob],
|
||||
entries: [],
|
||||
companyHolidays: [],
|
||||
stats: [buildStat()],
|
||||
holidays: {},
|
||||
sharedCalendars: [],
|
||||
incomingShares: [],
|
||||
selectedUserId: null,
|
||||
isFused: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
async function mount() {
|
||||
const view = renderHook(() => useMVacay());
|
||||
await waitFor(() => expect(view.result.current.selectedUserId).toBe(1));
|
||||
return view;
|
||||
}
|
||||
|
||||
describe('useMVacay', () => {
|
||||
it('FE-MOB-MVAC-001: defaults the active person to the signed-in user', async () => {
|
||||
const { result } = await mount();
|
||||
|
||||
expect(useVacayStore.getState().selectedUserId).toBe(1);
|
||||
expect(result.current.selectedUser?.username).toBe('alice');
|
||||
expect(result.current.selectedColor).toBe('#3b82f6');
|
||||
expect(result.current.selectedStat?.remaining).toBe(26);
|
||||
expect(result.current.tripDotColor).toBe('#3b82f6');
|
||||
});
|
||||
|
||||
it('FE-MOB-MVAC-002: falls back to the shared person color when the member has none', async () => {
|
||||
act(() => { useVacayStore.setState({ users: [{ id: 1, username: 'alice', color: null }] }); });
|
||||
const { result } = await mount();
|
||||
|
||||
expect(result.current.selectedColor).toBe('#6366f1');
|
||||
expect(result.current.tripDotColor).toBe('var(--m-st-info)');
|
||||
});
|
||||
|
||||
it('FE-MOB-MVAC-003: collects every day covered by an own trip inside the window', async () => {
|
||||
tripsRespond([
|
||||
{ start_date: '2026-03-30', end_date: '2026-04-02' },
|
||||
{ start_date: '2025-12-30', end_date: '2025-12-31' },
|
||||
{ start_date: null, end_date: '2026-05-01' },
|
||||
]);
|
||||
const { result } = await mount();
|
||||
|
||||
await waitFor(() => expect(result.current.tripDates.size).toBe(4));
|
||||
expect([...result.current.tripDates].sort()).toEqual([
|
||||
'2026-03-30', '2026-03-31', '2026-04-01', '2026-04-02',
|
||||
]);
|
||||
expect(result.current.tripDates.has('2025-12-30')).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-MOB-MVAC-004: leaves the trip overlay empty when the trip list fails', async () => {
|
||||
server.use(http.get('/api/trips', () => HttpResponse.json({ error: 'boom' }, { status: 500 })));
|
||||
const { result } = await mount();
|
||||
|
||||
await waitFor(() => expect(result.current.tripDates.size).toBe(0));
|
||||
});
|
||||
|
||||
it('FE-MOB-MVAC-005: derives the plan flags from their defaults without a plan', async () => {
|
||||
mocks.vacay = buildVacay({ plan: null });
|
||||
const { result } = await mount();
|
||||
|
||||
expect(result.current.blockWeekends).toBe(true);
|
||||
expect(result.current.companyHolidaysEnabled).toBe(true);
|
||||
expect(result.current.holidaysEnabled).toBe(false);
|
||||
expect(result.current.weekStart).toBe(1);
|
||||
expect(result.current.weekendDays).toEqual([0, 6]);
|
||||
});
|
||||
|
||||
it('FE-MOB-MVAC-006: reads configured weekend days and week start off the plan', async () => {
|
||||
mocks.vacay = buildVacay({
|
||||
plan: buildPlan({
|
||||
weekend_days: '5,6', week_start: 0, block_weekends: false,
|
||||
company_holidays_enabled: false, holidays_enabled: true,
|
||||
}),
|
||||
});
|
||||
const { result } = await mount();
|
||||
|
||||
expect(result.current.weekendDays).toEqual([5, 6]);
|
||||
expect(result.current.weekStart).toBe(0);
|
||||
expect(result.current.blockWeekends).toBe(false);
|
||||
expect(result.current.companyHolidaysEnabled).toBe(false);
|
||||
expect(result.current.holidaysEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-MOB-MVAC-007: groups entries per date and indexes the company holidays', async () => {
|
||||
const entries: VacayEntry[] = [
|
||||
{ date: '2026-06-15', user_id: 1 },
|
||||
{ date: '2026-06-15', user_id: 2, fraction: 0.5 },
|
||||
{ date: '2026-06-16', user_id: 1, kind: 'comp' },
|
||||
];
|
||||
act(() => { useVacayStore.setState({ entries, companyHolidays: [{ date: '2026-12-24' }] }); });
|
||||
const { result } = await mount();
|
||||
|
||||
expect(result.current.dayCtx.entryMap['2026-06-15']).toHaveLength(2);
|
||||
expect(result.current.dayCtx.entryMap['2026-06-16']).toHaveLength(1);
|
||||
expect(result.current.dayCtx.companyHolidaySet.has('2026-12-24')).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-MOB-MVAC-008: overlays visible shared calendars only', async () => {
|
||||
act(() => {
|
||||
useVacayStore.setState({
|
||||
sharedCalendars: [
|
||||
{
|
||||
share_id: 1, owner_id: 3, owner_name: 'carol', color: '#f59e0b', hidden: false,
|
||||
entries: [{ date: '2026-07-01' }], companyHolidays: [{ date: '2026-07-02' }],
|
||||
},
|
||||
{
|
||||
share_id: 2, owner_id: 4, owner_name: 'dan', color: '#10b981', hidden: true,
|
||||
entries: [{ date: '2026-07-03' }], companyHolidays: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
const { result } = await mount();
|
||||
|
||||
expect(result.current.dayCtx.sharedMap?.['2026-07-01']).toEqual([{ color: '#f59e0b' }]);
|
||||
expect(result.current.dayCtx.sharedMap?.['2026-07-02']).toEqual([{ color: '#f59e0b' }]);
|
||||
expect(result.current.dayCtx.sharedMap?.['2026-07-03']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('FE-MOB-MVAC-009: spans Jan–Dec on a calendar leave year', async () => {
|
||||
const { result } = await mount();
|
||||
|
||||
expect(result.current.isShiftedYear).toBe(false);
|
||||
expect(result.current.months).toHaveLength(12);
|
||||
expect(result.current.months[0]).toEqual({ year: 2026, month: 0 });
|
||||
expect(result.current.months[11]).toEqual({ year: 2026, month: 11 });
|
||||
expect(result.current.monthNamesShort[0]).toBe('Jan');
|
||||
});
|
||||
|
||||
it('FE-MOB-MVAC-010: rolls into the next calendar year for a fiscal leave year', async () => {
|
||||
act(() => {
|
||||
useVacayStore.setState({
|
||||
yearSettings: { year_type: 'fiscal', year_start_month: 7, year_start_day: 1, hire_date: null },
|
||||
});
|
||||
});
|
||||
const { result } = await mount();
|
||||
|
||||
expect(result.current.isShiftedYear).toBe(true);
|
||||
expect(result.current.months[0]).toEqual({ year: 2026, month: 6 });
|
||||
expect(result.current.months[11]).toEqual({ year: 2027, month: 5 });
|
||||
});
|
||||
|
||||
it('FE-MOB-MVAC-011: starts on the slot holding the current month', async () => {
|
||||
const { result } = await mount();
|
||||
|
||||
expect(result.current.monthSlot).toBe(new Date().getMonth());
|
||||
expect(result.current.activeMonth.month).toBe(new Date().getMonth());
|
||||
expect(result.current.monthNameLong).toBe(
|
||||
new Intl.DateTimeFormat('en-US', { month: 'long' }).format(new Date(2026, new Date().getMonth(), 1)),
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-MOB-MVAC-012: wraps the month stepper inside the twelve slots', async () => {
|
||||
const { result } = await mount();
|
||||
|
||||
act(() => { result.current.setMonthSlot(11); });
|
||||
act(() => { result.current.nextMonth(); });
|
||||
expect(result.current.monthSlot).toBe(0);
|
||||
|
||||
act(() => { result.current.prevMonth(); });
|
||||
expect(result.current.monthSlot).toBe(11);
|
||||
});
|
||||
|
||||
it('FE-MOB-MVAC-013: only a fused plan allows selecting another member', async () => {
|
||||
const { result } = await mount();
|
||||
|
||||
act(() => { result.current.selectPerson(2); });
|
||||
expect(useVacayStore.getState().selectedUserId).toBe(1);
|
||||
|
||||
act(() => { useVacayStore.setState({ isFused: true }); });
|
||||
act(() => { result.current.selectPerson(2); });
|
||||
expect(useVacayStore.getState().selectedUserId).toBe(2);
|
||||
expect(result.current.selectedUser?.username).toBe('bob');
|
||||
});
|
||||
|
||||
it('FE-MOB-MVAC-014: selecting yourself resets the logging mode to vacation', async () => {
|
||||
const { result } = await mount();
|
||||
|
||||
act(() => { result.current.setMode('company'); });
|
||||
expect(result.current.mode).toBe('company');
|
||||
|
||||
act(() => { result.current.selectPerson(1); });
|
||||
expect(result.current.mode).toBe('vacation');
|
||||
});
|
||||
|
||||
it('FE-MOB-MVAC-015: the year overview never logs a day', async () => {
|
||||
const toggleEntry = vi.fn(async (_date: string, _userId?: number, _fraction?: 0.5 | 1, _kind?: 'vacation' | 'comp') => {});
|
||||
act(() => { useVacayStore.setState({ toggleEntry }); });
|
||||
const { result } = await mount();
|
||||
|
||||
expect(result.current.view).toBe('grid');
|
||||
await act(async () => { await result.current.handleDayTap('2026-06-15'); });
|
||||
expect(toggleEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-MVAC-016: logs the selected person with the half-day and comp modifiers', async () => {
|
||||
const toggleEntry = vi.fn(async (_date: string, _userId?: number, _fraction?: 0.5 | 1, _kind?: 'vacation' | 'comp') => {});
|
||||
act(() => { useVacayStore.setState({ toggleEntry }); });
|
||||
const { result } = await mount();
|
||||
|
||||
act(() => { result.current.toggleView(); });
|
||||
expect(result.current.view).toBe('edit');
|
||||
|
||||
await act(async () => { await result.current.handleDayTap('2026-06-15'); });
|
||||
expect(toggleEntry).toHaveBeenLastCalledWith('2026-06-15', 1, 1, 'vacation');
|
||||
|
||||
act(() => { result.current.setHalfDay(true); result.current.setCompDay(true); });
|
||||
await act(async () => { await result.current.handleDayTap('2026-06-16'); });
|
||||
expect(toggleEntry).toHaveBeenLastCalledWith('2026-06-16', 1, 0.5, 'comp');
|
||||
});
|
||||
|
||||
it('FE-MOB-MVAC-017: blocked weekends and company holidays swallow the tap', async () => {
|
||||
const toggleEntry = vi.fn(async (_date: string, _userId?: number, _fraction?: 0.5 | 1, _kind?: 'vacation' | 'comp') => {});
|
||||
act(() => { useVacayStore.setState({ toggleEntry, companyHolidays: [{ date: '2026-12-24' }] }); });
|
||||
const { result } = await mount();
|
||||
act(() => { result.current.toggleView(); });
|
||||
|
||||
// 2026-06-13 is a Saturday.
|
||||
await act(async () => { await result.current.handleDayTap('2026-06-13'); });
|
||||
await act(async () => { await result.current.handleDayTap('2026-12-24'); });
|
||||
expect(toggleEntry).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => { await result.current.handleDayTap('2026-06-15'); });
|
||||
expect(toggleEntry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-MVAC-018: company mode toggles a company holiday and is inert while disabled', async () => {
|
||||
const toggleCompanyHoliday = vi.fn(async (_date: string) => {});
|
||||
act(() => { useVacayStore.setState({ toggleCompanyHoliday }); });
|
||||
mocks.vacay = buildVacay({ plan: buildPlan({ company_holidays_enabled: false }) });
|
||||
const { result, rerender } = await mount();
|
||||
|
||||
act(() => { result.current.toggleView(); });
|
||||
act(() => { result.current.setMode('company'); });
|
||||
await act(async () => { await result.current.handleDayTap('2026-12-24'); });
|
||||
expect(toggleCompanyHoliday).not.toHaveBeenCalled();
|
||||
|
||||
mocks.vacay = buildVacay({ plan: buildPlan({ company_holidays_enabled: true }) });
|
||||
act(() => { rerender(); });
|
||||
await act(async () => { await result.current.handleDayTap('2026-12-24'); });
|
||||
expect(toggleCompanyHoliday).toHaveBeenCalledWith('2026-12-24');
|
||||
});
|
||||
|
||||
it('FE-MOB-MVAC-019: the entitlement stepper caps at 365 and never drops below used', async () => {
|
||||
const updateVacationDays = vi.fn(async (_year: number, _days: number, _target?: number) => {});
|
||||
act(() => { useVacayStore.setState({ updateVacationDays, stats: [buildStat({ vacation_days: 365 })] }); });
|
||||
const { result } = await mount();
|
||||
|
||||
act(() => { result.current.allowInc(); });
|
||||
expect(updateVacationDays).toHaveBeenLastCalledWith(2026, 365, 1);
|
||||
|
||||
act(() => { result.current.allowDec(); });
|
||||
expect(updateVacationDays).toHaveBeenLastCalledWith(2026, 364, 1);
|
||||
|
||||
act(() => { useVacayStore.setState({ stats: [buildStat({ vacation_days: 10, used: 10, carried_over: 0 })] }); });
|
||||
act(() => { result.current.allowDec(); });
|
||||
expect(updateVacationDays).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('FE-MOB-MVAC-020: carried-over days let the entitlement drop below what was used', async () => {
|
||||
const updateVacationDays = vi.fn(async (_year: number, _days: number, _target?: number) => {});
|
||||
act(() => {
|
||||
useVacayStore.setState({ updateVacationDays, stats: [buildStat({ vacation_days: 10, used: 12, carried_over: 5 })] });
|
||||
});
|
||||
const { result } = await mount();
|
||||
|
||||
act(() => { result.current.allowDec(); });
|
||||
expect(updateVacationDays).toHaveBeenCalledWith(2026, 9, 1);
|
||||
});
|
||||
|
||||
it('FE-MOB-MVAC-021: the stepper is inert without a stat row for the person', async () => {
|
||||
const updateVacationDays = vi.fn(async (_year: number, _days: number, _target?: number) => {});
|
||||
act(() => { useVacayStore.setState({ updateVacationDays, stats: [] }); });
|
||||
const { result } = await mount();
|
||||
|
||||
act(() => { result.current.allowInc(); });
|
||||
act(() => { result.current.allowDec(); });
|
||||
expect(updateVacationDays).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-MVAC-022: the year switcher steps inside the list before creating a year', async () => {
|
||||
const setSelectedYear = vi.fn((_year: number) => undefined);
|
||||
const handleAddPrevYear = vi.fn(() => undefined);
|
||||
const handleAddNextYear = vi.fn(() => undefined);
|
||||
mocks.vacay = buildVacay({ setSelectedYear, handleAddPrevYear, handleAddNextYear });
|
||||
const { result } = await mount();
|
||||
|
||||
await act(async () => { await result.current.prevYear(); });
|
||||
expect(setSelectedYear).toHaveBeenLastCalledWith(2025);
|
||||
expect(handleAddPrevYear).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => { await result.current.nextYear(); });
|
||||
expect(handleAddNextYear).toHaveBeenCalledTimes(1);
|
||||
expect(setSelectedYear).toHaveBeenLastCalledWith(2027);
|
||||
});
|
||||
|
||||
it('FE-MOB-MVAC-022b: stepping forward inside the list never creates a year', async () => {
|
||||
const setSelectedYear = vi.fn((_year: number) => undefined);
|
||||
const handleAddNextYear = vi.fn(() => undefined);
|
||||
mocks.vacay = buildVacay({ selectedYear: 2025, setSelectedYear, handleAddNextYear });
|
||||
const { result } = await mount();
|
||||
|
||||
await act(async () => { await result.current.nextYear(); });
|
||||
expect(setSelectedYear).toHaveBeenCalledWith(2026);
|
||||
expect(handleAddNextYear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-MVAC-023: stepping past the oldest year creates it first', async () => {
|
||||
const setSelectedYear = vi.fn((_year: number) => undefined);
|
||||
const handleAddPrevYear = vi.fn(() => undefined);
|
||||
mocks.vacay = buildVacay({ years: [2026], selectedYear: 2026, setSelectedYear, handleAddPrevYear });
|
||||
const { result } = await mount();
|
||||
|
||||
await act(async () => { await result.current.prevYear(); });
|
||||
expect(handleAddPrevYear).toHaveBeenCalledTimes(1);
|
||||
expect(setSelectedYear).toHaveBeenCalledWith(2025);
|
||||
});
|
||||
|
||||
it('FE-MOB-MVAC-024: a rejected share toggle surfaces the server message', async () => {
|
||||
const setShareHidden = vi.fn(async (_shareId: number, _hidden: boolean) => {
|
||||
throw { response: { data: { error: 'Share gone' } }, message: 'nope' };
|
||||
});
|
||||
act(() => { useVacayStore.setState({ setShareHidden }); });
|
||||
const { result } = await mount();
|
||||
|
||||
await act(async () => { result.current.toggleShareHidden(7, true); });
|
||||
await waitFor(() => expect(toasts).toHaveLength(1));
|
||||
expect(toasts[0]).toEqual({ type: 'error', message: 'Share gone' });
|
||||
expect(setShareHidden).toHaveBeenCalledWith(7, true);
|
||||
});
|
||||
|
||||
it('FE-MOB-MVAC-025: the back action returns to the dashboard', async () => {
|
||||
const { result } = await mount();
|
||||
|
||||
act(() => { result.current.goBack(); });
|
||||
expect(navigateMock).toHaveBeenCalledWith('/dashboard');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
// FE-MOB-MVACDM-001 to FE-MOB-MVACDM-007
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { dayVisual, hatchTint, holidayInk, personTint } from '../../../../src/mobile/screens/vacay/vacayDayModel';
|
||||
import type { DayVisualContext } from '../../../../src/mobile/screens/vacay/vacayDayModel';
|
||||
|
||||
function ctx(over: Partial<DayVisualContext> = {}): DayVisualContext {
|
||||
return {
|
||||
todayStr: '2099-01-01',
|
||||
entryMap: {},
|
||||
companyHolidaySet: new Set(),
|
||||
companyHolidaysEnabled: true,
|
||||
holidays: {},
|
||||
weekendDays: [0, 6],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('vacayDayModel colors', () => {
|
||||
it('FE-MOB-MVACDM-001: a colour that is not a plain hex is passed through untouched', () => {
|
||||
expect(personTint('var(--m-act)')).toBe('var(--m-act)');
|
||||
expect(holidayInk('rgb(1,2,3)')).toBe('var(--m-ink)');
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACDM-002: a neutral grey keeps hue and saturation at zero', () => {
|
||||
expect(personTint('#808080')).toBe('hsl(0 0% 85%)');
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACDM-003: the hue is derived from whichever channel dominates', () => {
|
||||
// Red-, green- and blue-dominant inputs each take a different hue branch.
|
||||
expect(personTint('#ef4444')).toMatch(/^hsl\(0 /);
|
||||
expect(personTint('#22c55e')).toMatch(/^hsl\(142 /);
|
||||
expect(personTint('#3b82f6')).toMatch(/^hsl\(217 /);
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACDM-004: a half day is flagged so the cell can badge it', () => {
|
||||
const visual = dayVisual('2026-06-15', 1, ctx({
|
||||
entryMap: { '2026-06-15': [{ date: '2026-06-15', user_id: 1, fraction: 0.5, person_color: '#3b82f6' }] },
|
||||
}));
|
||||
|
||||
expect(visual.half).toBe(true);
|
||||
expect(visual.segments).toEqual([{ color: personTint('#3b82f6'), comp: false }]);
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACDM-005: an all-comp day is hatched and carries its own text shadow', () => {
|
||||
const visual = dayVisual('2026-06-15', 1, ctx({
|
||||
entryMap: { '2026-06-15': [{ date: '2026-06-15', user_id: 1, kind: 'comp', person_color: '#3b82f6' }] },
|
||||
}));
|
||||
|
||||
expect(visual.background).toBe(hatchTint(personTint('#3b82f6')));
|
||||
expect(visual.textShadow).toBeDefined();
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACDM-006: a plain school-break day gets the wash and a tinted ink', () => {
|
||||
const visual = dayVisual('2026-06-15', 1, ctx({
|
||||
holidays: {
|
||||
'2026-06-15': [{ name: 'Sommer', localName: 'Sommer', color: '#bbf7d0', label: null, type: 'school_holiday' }],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(visual.school).toEqual(['#bbf7d0']);
|
||||
expect(visual.background).toBe('color-mix(in srgb, #bbf7d0 15%, transparent)');
|
||||
expect(visual.numColor).toBe(holidayInk('#bbf7d0'));
|
||||
});
|
||||
|
||||
it('FE-MOB-MVACDM-007: a school break on a logged day only adds the accent band', () => {
|
||||
const visual = dayVisual('2026-06-15', 1, ctx({
|
||||
entryMap: { '2026-06-15': [{ date: '2026-06-15', user_id: 1, person_color: '#3b82f6' }] },
|
||||
holidays: {
|
||||
'2026-06-15': [{ name: 'Sommer', localName: 'Sommer', color: '#bbf7d0', label: null, type: 'school_holiday' }],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(visual.school).toEqual(['#bbf7d0']);
|
||||
expect(visual.background).toBe(personTint('#3b82f6'));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user