Files
TREK/client/src/components/Admin/addons/useAddonManager.test.ts
T
Maurice 73237ffedb feat(tours): bike tours in the planner, CyclOSM, and the planned cards' last points (BIK-01 to BIK-06, RTE-01, IO-01, LIB-02)
The tour type is a dropdown; a bike tour picks its profile, routes for it,
says when a point lies off every way, and shows what it runs on: surfaces,
kinds of way and, for a mountain bike, the trail grades, each pointed at on
the map. A type's own map (OpenHikingMap for a hike, CyclOSM for a bike tour)
is offered while a tour of that type is open, with an admin switch each.

From the planned cards: a hike avoids steep climbs, steps or ferries, nothing
else; a saved tour can be shared where the platform takes GPX files; and the
tours rail adds a tour from one menu: plan new, import GPX, or take one over
from another trip.
2026-10-19 21:33:49 +02:00

160 lines
7.4 KiB
TypeScript

import { act, renderHook, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { adminApi } from '../../../api/client';
import { cycleMapAdminApi, hikingMapAdminApi } from '../../../api/tourBaseMaps';
import { wandererAdminApi } from '../../../api/wanderer';
import { useAddonStore } from '../../../store/addonStore';
import type { Addon } from './addonModel';
import { useAddonManager } from './useAddonManager';
// FE-HOOK-ADDONMGR-001 to FE-HOOK-ADDONMGR-008
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
vi.mock('../../../i18n', () => ({ useTranslation: () => ({ t: (k: string) => k }) }));
function addon(id: string, type: string, enabled = true, over: Partial<Addon> = {}): Addon {
return { id, name: id, description: `${id} addon`, icon: 'Puzzle', type, enabled, ...over };
}
const ADDONS = [
addon('todo', 'trip'),
addon('memories', 'trip', true, { icon: 'Image' }),
addon('journey', 'global'),
addon('documents', 'trip'),
addon('llm_parsing', 'integration', false),
addon('immich', 'photo_provider', false),
addon('gdrive', 'document_provider'),
];
let loadAddons: ReturnType<typeof vi.fn<() => Promise<void>>>;
beforeEach(() => {
toast.success.mockReset();
toast.error.mockReset();
loadAddons = vi.fn<() => Promise<void>>().mockResolvedValue(undefined);
useAddonStore.setState({ loadAddons });
vi.spyOn(adminApi, 'addons').mockResolvedValue({ addons: ADDONS });
vi.spyOn(adminApi, 'updateAddon').mockResolvedValue({});
vi.spyOn(wandererAdminApi, 'get').mockResolvedValue({ enabled: false });
vi.spyOn(hikingMapAdminApi, 'get').mockResolvedValue({ enabled: true });
vi.spyOn(cycleMapAdminApi, 'get').mockResolvedValue({ enabled: true });
});
afterEach(() => vi.restoreAllMocks());
async function loaded() {
const hook = renderHook(() => useAddonManager());
await waitFor(() => expect(hook.result.current.loading).toBe(false));
return hook;
}
const enabledOf = (addons: Addon[], id: string) => addons.find((a) => a.id === id)?.enabled;
/** One of the map switches under Tours, by its key. */
function mapSwitch(manager: ReturnType<typeof useAddonManager>, key: 'hiking' | 'cycle') {
const found = manager.mapSwitches.find((entry) => entry.key === key);
if (!found) throw new Error(`no ${key} map switch`);
return found;
}
describe('useAddonManager', () => {
it('FE-HOOK-ADDONMGR-001: loads once and sorts addons into trip, global, integration and the two shelves', async () => {
const { result, rerender } = await loaded();
rerender();
expect(adminApi.addons).toHaveBeenCalledTimes(1);
const ids = (list: { id?: string; key?: string }[]) => list.map((a) => a.id ?? a.key);
expect(ids(result.current.tripAddons)).toEqual(['todo', 'documents']);
expect(ids(result.current.globalAddons)).toEqual(['journey']);
expect(ids(result.current.integrationAddons)).toEqual(['llm_parsing']);
expect(result.current.providerOptions).toEqual([
expect.objectContaining({ key: 'immich', label: 'immich', description: 'immich addon', enabled: false }),
]);
expect(result.current.documentProviderOptions.map((o) => o.key)).toEqual(['gdrive']);
});
it('FE-HOOK-ADDONMGR-002: a failed load toasts the addon error and ends loading', async () => {
vi.mocked(adminApi.addons).mockRejectedValue(new Error('down'));
const { result } = await loaded();
expect(toast.error).toHaveBeenCalledWith('admin.addons.toast.error');
expect(result.current.addons).toEqual([]);
});
it('FE-HOOK-ADDONMGR-003: a toggle flips at once, saves, refreshes the store and toasts', async () => {
const { result } = await loaded();
await act(() => result.current.handleToggle(ADDONS[0]));
expect(adminApi.updateAddon).toHaveBeenCalledWith('todo', { enabled: false });
expect(enabledOf(result.current.addons, 'todo')).toBe(false);
expect(loadAddons).toHaveBeenCalledTimes(1);
expect(adminApi.addons).toHaveBeenCalledTimes(1);
expect(toast.success).toHaveBeenCalledWith('admin.addons.toast.updated');
});
it('FE-HOOK-ADDONMGR-004: a failed toggle rolls back only its own row', async () => {
let fail!: (e: unknown) => void;
vi.mocked(adminApi.updateAddon)
.mockReturnValueOnce(new Promise((_, reject) => (fail = reject)))
.mockResolvedValueOnce({});
const { result } = await loaded();
let pending!: Promise<void>;
act(() => {
pending = result.current.handleToggle(ADDONS[0]);
});
await act(() => result.current.handleToggle(ADDONS[5]));
await act(async () => {
fail(new Error('x'));
await pending;
});
expect(enabledOf(result.current.addons, 'todo')).toBe(true);
expect(enabledOf(result.current.addons, 'immich')).toBe(true);
expect(toast.error).toHaveBeenCalledWith('admin.addons.toast.error');
expect(loadAddons).toHaveBeenCalledTimes(1);
});
it('FE-HOOK-ADDONMGR-005: toggling Journey or Documents reads the list again for the cascaded providers', async () => {
const { result } = await loaded();
await act(() => result.current.handleToggle(ADDONS[2]));
expect(adminApi.addons).toHaveBeenCalledTimes(2);
await act(() => result.current.handleToggle(ADDONS[3]));
expect(adminApi.addons).toHaveBeenCalledTimes(3);
});
it('FE-HOOK-ADDONMGR-006: a provider option toggles its provider like any addon', async () => {
const { result } = await loaded();
await act(() => result.current.providerOptions[0].toggle());
expect(adminApi.updateAddon).toHaveBeenCalledWith('immich', { enabled: true });
expect(result.current.providerOptions[0].enabled).toBe(true);
await act(() => result.current.documentProviderOptions[0].toggle());
expect(adminApi.updateAddon).toHaveBeenLastCalledWith('gdrive', { enabled: false });
});
it('FE-HOOK-ADDONMGR-007: the hiking map switch under Tours loads, flips, saves and refreshes the store (MAP-01)', async () => {
const update = vi.spyOn(hikingMapAdminApi, 'update').mockResolvedValue({ enabled: false });
const { result } = await loaded();
await waitFor(() => expect(mapSwitch(result.current, 'hiking').enabled).toBe(true));
await act(() => mapSwitch(result.current, 'hiking').toggle());
expect(update).toHaveBeenCalledWith(false);
expect(mapSwitch(result.current, 'hiking').enabled).toBe(false);
expect(loadAddons).toHaveBeenCalled();
expect(toast.success).toHaveBeenCalledWith('admin.addons.toast.updated');
});
it('FE-HOOK-ADDONMGR-009: the cycling map switch under Tours loads, flips and saves on its own (BIK-06)', async () => {
const update = vi.spyOn(cycleMapAdminApi, 'update').mockResolvedValue({ enabled: false });
const { result } = await loaded();
await waitFor(() => expect(mapSwitch(result.current, 'cycle').enabled).toBe(true));
await act(() => mapSwitch(result.current, 'cycle').toggle());
expect(update).toHaveBeenCalledWith(false);
expect(mapSwitch(result.current, 'cycle').enabled).toBe(false);
expect(mapSwitch(result.current, 'hiking').enabled).toBe(true);
});
it('FE-HOOK-ADDONMGR-008: a failed save puts the switch under Tours back and says so', async () => {
vi.spyOn(wandererAdminApi, 'update').mockRejectedValue(new Error('500'));
const { result } = await loaded();
await act(() => result.current.toggleToursWanderer());
expect(result.current.toursWanderer).toBe(false);
expect(toast.error).toHaveBeenCalledWith('admin.addons.toast.error');
});
});