Files
TREK/client/tests/unit/stores/settingsStore.test.ts
T
jubnl ae71bcfd1e feat(settings): make the display currency optional, falling back to the trip's
Costs already resolved `default_currency || trip.currency || 'EUR'`, but the
setting could never actually be empty: the store seeded 'USD', so a user who had
never touched it silently had every trip converted into dollars, and the picker
offered no way to unset it.

Seed it empty and lead the picker with a "Trip currency" option, so an unset
preference means "show each trip in its own currency" instead of forcing them all
through one code. An explicit empty value persists and beats the admin-set
instance default — it is a deliberate choice, not an absence.

This also brings the public share's fallback to life: the share payload has
resolved sharer's currency -> trip currency since #1361, but the trip-currency
branch was unreachable while every owner had a currency forced on them.

Plugins are handed `formats.currency` as a concrete code, so PluginFrame now
resolves the same chain rather than passing an empty string through the bridge.
2026-07-13 22:23:15 +02:00

223 lines
7.9 KiB
TypeScript

import { describe, it, expect, beforeEach } from 'vitest';
import { http, HttpResponse } from 'msw';
import { server } from '../../helpers/msw/server';
import { useSettingsStore } from '../../../src/store/settingsStore';
import { resetAllStores } from '../../helpers/store';
import { buildSettings } from '../../helpers/factories';
beforeEach(() => {
resetAllStores();
});
describe('settingsStore', () => {
describe('FE-SETTINGS-001: loadSettings()', () => {
it('fetches settings and updates store', async () => {
const settings = buildSettings({ default_currency: 'EUR', language: 'de' });
server.use(
http.get('/api/settings', () => HttpResponse.json({ settings }))
);
await useSettingsStore.getState().loadSettings();
const state = useSettingsStore.getState();
expect(state.settings.default_currency).toBe('EUR');
expect(state.settings.language).toBe('de');
expect(state.isLoaded).toBe(true);
});
});
describe('FE-SETTINGS-002: updateSetting() optimistic update', () => {
it('immediately updates local state before API resolves', async () => {
// The store's set() is called synchronously before the first await (settingsApi.set)
// so state is visible without needing to await the full action.
const promise = useSettingsStore.getState().updateSetting('default_currency', 'GBP');
// Check optimistic state — no await needed here
expect(useSettingsStore.getState().settings.default_currency).toBe('GBP');
// Let the API call finish to avoid dangling promises
await promise;
});
});
describe('FE-SETTINGS-003: updateSetting() reverts on API failure', () => {
it('throws when API fails', async () => {
server.use(
http.put('/api/settings', () =>
HttpResponse.json({ error: 'Server error' }, { status: 500 })
)
);
// The store optimistically sets, then throws — the revert is a throw
await expect(
useSettingsStore.getState().updateSetting('default_currency', 'GBP')
).rejects.toThrow();
});
});
describe('FE-SETTINGS-004: Language change', () => {
it('updates language field and localStorage', async () => {
await useSettingsStore.getState().updateSetting('language', 'fr');
const state = useSettingsStore.getState();
expect(state.settings.language).toBe('fr');
expect(localStorage.getItem('app_language')).toBe('fr');
});
});
describe('FE-SETTINGS-005: loadSettings failure', () => {
it('sets isLoaded: true even on API failure (graceful)', async () => {
server.use(
http.get('/api/settings', () =>
HttpResponse.json({ error: 'Server error' }, { status: 500 })
)
);
await useSettingsStore.getState().loadSettings();
const state = useSettingsStore.getState();
expect(state.isLoaded).toBe(true);
});
});
describe('FE-STORE-SETTINGS-006: setLanguageLocal updates state and localStorage', () => {
it('sets language in state and localStorage without an API call', () => {
useSettingsStore.getState().setLanguageLocal('ja');
const state = useSettingsStore.getState();
expect(state.settings.language).toBe('ja');
expect(localStorage.getItem('app_language')).toBe('ja');
});
});
describe('FE-STORE-SETTINGS-007: setLanguageLocal without prior localStorage value', () => {
it('writes to localStorage even when no prior value exists', () => {
localStorage.clear();
useSettingsStore.getState().setLanguageLocal('ko');
const state = useSettingsStore.getState();
expect(state.settings.language).toBe('ko');
expect(localStorage.getItem('app_language')).toBe('ko');
});
});
describe('FE-STORE-SETTINGS-008: updateSettings bulk update', () => {
it('updates multiple settings keys and calls bulk API', async () => {
await useSettingsStore.getState().updateSettings({ dark_mode: true, default_currency: 'JPY' });
const state = useSettingsStore.getState();
expect(state.settings.dark_mode).toBe(true);
expect(state.settings.default_currency).toBe('JPY');
});
});
describe('FE-STORE-SETTINGS-009: updateSettings optimistic update', () => {
it('updates state synchronously before API resolves', async () => {
const promise = useSettingsStore.getState().updateSettings({ dark_mode: true });
expect(useSettingsStore.getState().settings.dark_mode).toBe(true);
await promise;
});
});
describe('FE-STORE-SETTINGS-010: updateSettings API failure throws', () => {
it('throws when bulk API returns 500', async () => {
server.use(
http.post('/api/settings/bulk', () =>
HttpResponse.json({ error: 'Server error' }, { status: 500 })
)
);
await expect(
useSettingsStore.getState().updateSettings({ dark_mode: true })
).rejects.toThrow();
});
});
describe('FE-STORE-SETTINGS-011: updateSetting non-language key does not write to localStorage', () => {
it('does not modify app_language in localStorage', async () => {
const before = localStorage.getItem('app_language');
await useSettingsStore.getState().updateSetting('dark_mode', true);
expect(localStorage.getItem('app_language')).toBe(before);
});
});
describe('FE-STORE-SETTINGS-012: loadSettings merges server values with defaults', () => {
it('preserves default keys not returned by server', async () => {
server.use(
http.get('/api/settings', () =>
HttpResponse.json({ settings: { dark_mode: true } })
)
);
await useSettingsStore.getState().loadSettings();
const state = useSettingsStore.getState();
expect(state.settings.dark_mode).toBe(true);
expect(state.settings.language).toBe('en');
// No display currency of their own: Costs then follows each trip's own currency
// rather than forcing every trip through one code.
expect(state.settings.default_currency).toBe('');
});
});
describe('FE-STORE-SETTINGS-013: updateSetting for time_format', () => {
it('updates time_format in state', async () => {
await useSettingsStore.getState().updateSetting('time_format', '24h');
expect(useSettingsStore.getState().settings.time_format).toBe('24h');
});
});
describe('FE-STORE-SETTINGS-015: setLanguageTransient updates state without touching localStorage', () => {
it('sets language in state but does not write to localStorage', () => {
localStorage.clear();
useSettingsStore.getState().setLanguageTransient('fr');
expect(useSettingsStore.getState().settings.language).toBe('fr');
expect(localStorage.getItem('app_language')).toBeNull();
});
});
describe('FE-STORE-SETTINGS-016: setLanguageTransient rejects unsupported language code', () => {
it('leaves state unchanged for an unknown code', () => {
const before = useSettingsStore.getState().settings.language;
useSettingsStore.getState().setLanguageTransient('xx');
expect(useSettingsStore.getState().settings.language).toBe(before);
});
});
describe('FE-STORE-SETTINGS-017: setLanguageTransient does not overwrite an explicit localStorage choice', () => {
it('localStorage remains unchanged after a transient set', () => {
localStorage.setItem('app_language', 'de');
useSettingsStore.getState().setLanguageTransient('es');
expect(localStorage.getItem('app_language')).toBe('de');
});
});
describe('FE-STORE-SETTINGS-014: updateSetting API failure leaves optimistic state', () => {
it('throws on API failure but keeps the optimistic state', async () => {
server.use(
http.put('/api/settings', () =>
HttpResponse.json({ error: 'Server error' }, { status: 500 })
)
);
await expect(
useSettingsStore.getState().updateSetting('default_zoom', 15)
).rejects.toThrow();
expect(useSettingsStore.getState().settings.default_zoom).toBe(15);
});
});
});