mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
test(client): expand frontend test suite to 69.1% coverage
Add and extend tests across 32 files (+10 595 lines) covering Admin panels (AuditLog, Backup, DevNotifications, GitHub), Collab (Chat, Notes, Panel, Polls), Planner (DayDetailPanel, DayPlanSidebar), Settings (DisplaySettings, Integrations, MapSettings), Files (FileManager, FilesPage), Map, Layout (DemoBanner, InAppNotificationBell), shared pickers (CustomDateTimePicker, CustomTimePicker), Vacay holidays, pages (Dashboard, Login), unit stores (authStore, inAppNotificationStore), API (authUrl, client integration), and i18n. Also updates sonar-project.properties and MSW trip handlers to support the new cases.
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../helpers/msw/server';
|
||||
import { getAuthUrl, fetchImageAsBlob, clearImageQueue } from '../../../src/api/authUrl';
|
||||
|
||||
// Flush microtasks + a macro-task so async handlers finish
|
||||
const flushPromises = () => new Promise<void>(r => setTimeout(r, 10));
|
||||
|
||||
beforeEach(() => {
|
||||
clearImageQueue();
|
||||
vi.restoreAllMocks(); // restore any vi.spyOn() wrappers from the previous test
|
||||
});
|
||||
|
||||
// ── getAuthUrl ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getAuthUrl', () => {
|
||||
describe('FE-COMP-AUTHURL-001: empty URL returns early', () => {
|
||||
it('returns empty string without hitting the network', async () => {
|
||||
const result = await getAuthUrl('', 'download');
|
||||
expect(result).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-COMP-AUTHURL-002: token appended with ?', () => {
|
||||
it('appends token as first query param when URL has no query string', async () => {
|
||||
server.use(
|
||||
http.post('/api/auth/resource-token', () =>
|
||||
HttpResponse.json({ token: 'abc123' })
|
||||
)
|
||||
);
|
||||
const result = await getAuthUrl('/uploads/file.pdf', 'download');
|
||||
expect(result).toBe('/uploads/file.pdf?token=abc123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-COMP-AUTHURL-003: token appended with &', () => {
|
||||
it('appends token as additional query param when URL already has a query string', async () => {
|
||||
server.use(
|
||||
http.post('/api/auth/resource-token', () =>
|
||||
HttpResponse.json({ token: 'xyz' })
|
||||
)
|
||||
);
|
||||
const result = await getAuthUrl('/uploads/file.pdf?size=lg', 'download');
|
||||
expect(result).toBe('/uploads/file.pdf?size=lg&token=xyz');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-COMP-AUTHURL-004: non-ok API response returns original URL', () => {
|
||||
it('returns original URL unchanged when resource-token returns 500', async () => {
|
||||
server.use(
|
||||
http.post('/api/auth/resource-token', () =>
|
||||
HttpResponse.json({}, { status: 500 })
|
||||
)
|
||||
);
|
||||
const result = await getAuthUrl('/uploads/file.pdf', 'download');
|
||||
expect(result).toBe('/uploads/file.pdf');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-COMP-AUTHURL-005: fetch throws returns original URL', () => {
|
||||
it('returns original URL when fetch throws a network error', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(
|
||||
new TypeError('Network error')
|
||||
);
|
||||
const result = await getAuthUrl('/uploads/file.pdf', 'download');
|
||||
expect(result).toBe('/uploads/file.pdf');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── fetchImageAsBlob ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('fetchImageAsBlob', () => {
|
||||
describe('FE-COMP-AUTHURL-006: empty URL returns empty string', () => {
|
||||
it('resolves to empty string without network call', async () => {
|
||||
const result = await fetchImageAsBlob('');
|
||||
expect(result).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-COMP-AUTHURL-007: successful fetch returns blob object URL', () => {
|
||||
it('resolves to a blob URL for a valid image response', async () => {
|
||||
server.use(
|
||||
http.get('/uploads/photo.jpg', () =>
|
||||
new HttpResponse(new Blob(['fake-image'], { type: 'image/jpeg' }), {
|
||||
status: 200,
|
||||
})
|
||||
)
|
||||
);
|
||||
const result = await fetchImageAsBlob('/uploads/photo.jpg');
|
||||
// URL.createObjectURL is native in Node 20+; just assert it's a blob URL
|
||||
expect(result).toMatch(/^blob:/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-COMP-AUTHURL-008: non-ok response resolves to empty string', () => {
|
||||
it('resolves to empty string when image URL returns 404', async () => {
|
||||
server.use(
|
||||
http.get('/uploads/missing.jpg', () =>
|
||||
HttpResponse.json({}, { status: 404 })
|
||||
)
|
||||
);
|
||||
const result = await fetchImageAsBlob('/uploads/missing.jpg');
|
||||
expect(result).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-COMP-AUTHURL-009: fetch throws resolves to empty string', () => {
|
||||
it('resolves to empty string when fetch rejects', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(
|
||||
new TypeError('Network error')
|
||||
);
|
||||
const result = await fetchImageAsBlob('/uploads/error.jpg');
|
||||
expect(result).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Concurrency tests use vi.spyOn(fetch) for synchronous barrier control ──
|
||||
// When the spy mock runs, it executes synchronously up to its first `await`,
|
||||
// so `resolvers.push(r)` happens synchronously inside fetchImageAsBlob(), giving
|
||||
// us deterministic access to in-flight requests without needing flushPromises().
|
||||
|
||||
describe('FE-COMP-AUTHURL-010: concurrency cap at MAX_CONCURRENT=6', () => {
|
||||
it('fires at most 6 requests simultaneously', async () => {
|
||||
let concurrent = 0;
|
||||
let maxConcurrent = 0;
|
||||
const resolvers: Array<() => void> = [];
|
||||
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => {
|
||||
concurrent++;
|
||||
maxConcurrent = Math.max(maxConcurrent, concurrent);
|
||||
await new Promise<void>(r => resolvers.push(r));
|
||||
concurrent--;
|
||||
return new Response(new Blob(['img'], { type: 'image/jpeg' }), { status: 200 });
|
||||
});
|
||||
|
||||
const urls = Array.from({ length: 8 }, (_, i) => `/uploads/img${i}.jpg`);
|
||||
const promises = urls.map(url => fetchImageAsBlob(url));
|
||||
|
||||
// After synchronous calls: 6 run()s called fetch() and pushed to resolvers,
|
||||
// 2 are in the module queue
|
||||
expect(resolvers.length).toBe(6);
|
||||
expect(maxConcurrent).toBeLessThanOrEqual(6);
|
||||
|
||||
// Drain iteratively: each pass resolves current in-flight requests,
|
||||
// then the next batch from the queue starts and pushes new resolvers
|
||||
while (resolvers.length > 0) {
|
||||
resolvers.splice(0).forEach(r => r());
|
||||
await flushPromises();
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
expect(maxConcurrent).toBeLessThanOrEqual(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-COMP-AUTHURL-011: queued request runs after active slot frees', () => {
|
||||
it('7th request eventually resolves once one of the 6 active slots is freed', async () => {
|
||||
const resolvers: Array<() => void> = [];
|
||||
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => {
|
||||
await new Promise<void>(r => resolvers.push(r));
|
||||
return new Response(new Blob(['img'], { type: 'image/jpeg' }), { status: 200 });
|
||||
});
|
||||
|
||||
const urls = Array.from({ length: 7 }, (_, i) => `/uploads/queue${i}.jpg`);
|
||||
const promises = urls.map(url => fetchImageAsBlob(url));
|
||||
|
||||
// 6 in-flight, 1 queued
|
||||
expect(resolvers.length).toBe(6);
|
||||
|
||||
// Resolve the 6 active requests
|
||||
resolvers.splice(0).forEach(r => r());
|
||||
await flushPromises();
|
||||
|
||||
// 7th should now have started
|
||||
expect(resolvers.length).toBe(1);
|
||||
|
||||
// Resolve the 7th
|
||||
resolvers.splice(0).forEach(r => r());
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
expect(results).toHaveLength(7);
|
||||
results.forEach(r => expect(r).toMatch(/^blob:/));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── clearImageQueue ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('clearImageQueue', () => {
|
||||
describe('FE-COMP-AUTHURL-012: clearImageQueue discards pending entries', () => {
|
||||
it('removes queued items so they never execute after active slots drain', async () => {
|
||||
const resolvers: Array<() => void> = [];
|
||||
const createObjectURLSpy = vi.spyOn(URL, 'createObjectURL');
|
||||
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => {
|
||||
await new Promise<void>(r => resolvers.push(r));
|
||||
return new Response(new Blob(['img'], { type: 'image/jpeg' }), { status: 200 });
|
||||
});
|
||||
|
||||
const urls = Array.from({ length: 7 }, (_, i) => `/uploads/clear${i}.jpg`);
|
||||
const promises = urls.map(url => fetchImageAsBlob(url));
|
||||
|
||||
// 6 in-flight, 1 queued
|
||||
expect(resolvers.length).toBe(6);
|
||||
|
||||
// Discard the queued 7th request
|
||||
clearImageQueue();
|
||||
|
||||
// Resolve the 6 active requests and let them drain
|
||||
resolvers.splice(0).forEach(r => r());
|
||||
await flushPromises();
|
||||
|
||||
// 6 active slots completed; queue was cleared so the 7th never ran
|
||||
expect(createObjectURLSpy).toHaveBeenCalledTimes(6);
|
||||
|
||||
// First 6 promises resolved; 7th is orphaned (never resolves)
|
||||
await Promise.all(promises.slice(0, 6));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
import {
|
||||
TranslationProvider,
|
||||
useTranslation,
|
||||
getLocaleForLanguage,
|
||||
getIntlLanguage,
|
||||
isRtlLanguage,
|
||||
SUPPORTED_LANGUAGES,
|
||||
} from '../../../src/i18n'
|
||||
import { resetAllStores, seedStore } from '../../helpers/store'
|
||||
import { useSettingsStore } from '../../../src/store/settingsStore'
|
||||
import { buildSettings } from '../../helpers/factories'
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// ── FE-COMP-I18N-001: Barrel re-exports ───────────────────────────────────────
|
||||
|
||||
describe('barrel re-exports', () => {
|
||||
it('FE-COMP-I18N-001: all named exports are defined with expected types', () => {
|
||||
expect(TranslationProvider).toBeDefined()
|
||||
expect(typeof TranslationProvider).toBe('function')
|
||||
expect(useTranslation).toBeDefined()
|
||||
expect(typeof useTranslation).toBe('function')
|
||||
expect(getLocaleForLanguage).toBeDefined()
|
||||
expect(typeof getLocaleForLanguage).toBe('function')
|
||||
expect(getIntlLanguage).toBeDefined()
|
||||
expect(typeof getIntlLanguage).toBe('function')
|
||||
expect(isRtlLanguage).toBeDefined()
|
||||
expect(typeof isRtlLanguage).toBe('function')
|
||||
expect(SUPPORTED_LANGUAGES).toBeDefined()
|
||||
expect(Array.isArray(SUPPORTED_LANGUAGES)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ── FE-COMP-I18N-002/003: getLocaleForLanguage ────────────────────────────────
|
||||
|
||||
describe('getLocaleForLanguage', () => {
|
||||
it('FE-COMP-I18N-002: returns correct locale for known languages', () => {
|
||||
expect(getLocaleForLanguage('en')).toBe('en-US')
|
||||
expect(getLocaleForLanguage('de')).toBe('de-DE')
|
||||
expect(getLocaleForLanguage('zh-TW')).toBe('zh-TW')
|
||||
expect(getLocaleForLanguage('ar')).toBe('ar-SA')
|
||||
expect(getLocaleForLanguage('br')).toBe('pt-BR')
|
||||
})
|
||||
|
||||
it('FE-COMP-I18N-003: falls back to en-US for unknown language codes', () => {
|
||||
expect(getLocaleForLanguage('xx')).toBe('en-US')
|
||||
})
|
||||
})
|
||||
|
||||
// ── FE-COMP-I18N-004/005/006: getIntlLanguage ─────────────────────────────────
|
||||
|
||||
describe('getIntlLanguage', () => {
|
||||
it('FE-COMP-I18N-004: returns language code for known supported languages', () => {
|
||||
expect(getIntlLanguage('de')).toBe('de')
|
||||
expect(getIntlLanguage('fr')).toBe('fr')
|
||||
expect(getIntlLanguage('zh-TW')).toBe('zh-TW')
|
||||
})
|
||||
|
||||
it('FE-COMP-I18N-005: maps br to pt-BR', () => {
|
||||
expect(getIntlLanguage('br')).toBe('pt-BR')
|
||||
})
|
||||
|
||||
it('FE-COMP-I18N-006: falls back to en for unknown codes', () => {
|
||||
expect(getIntlLanguage('xx')).toBe('en')
|
||||
})
|
||||
})
|
||||
|
||||
// ── FE-COMP-I18N-007/008: isRtlLanguage ──────────────────────────────────────
|
||||
|
||||
describe('isRtlLanguage', () => {
|
||||
it('FE-COMP-I18N-007: returns true only for Arabic', () => {
|
||||
expect(isRtlLanguage('ar')).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-COMP-I18N-008: returns false for all other supported languages', () => {
|
||||
expect(isRtlLanguage('en')).toBe(false)
|
||||
expect(isRtlLanguage('de')).toBe(false)
|
||||
expect(isRtlLanguage('zh-TW')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ── FE-COMP-I18N-009: SUPPORTED_LANGUAGES ────────────────────────────────────
|
||||
|
||||
describe('SUPPORTED_LANGUAGES', () => {
|
||||
it('FE-COMP-I18N-009: contains expected entries with value/label shape', () => {
|
||||
expect(Array.isArray(SUPPORTED_LANGUAGES)).toBe(true)
|
||||
expect(SUPPORTED_LANGUAGES).toHaveLength(14)
|
||||
expect(SUPPORTED_LANGUAGES).toContainEqual({ value: 'en', label: 'English' })
|
||||
expect(SUPPORTED_LANGUAGES).toContainEqual({ value: 'ar', label: 'العربية' })
|
||||
})
|
||||
})
|
||||
|
||||
// ── FE-COMP-I18N-010 to 015: TranslationProvider + useTranslation ─────────────
|
||||
|
||||
describe('TranslationProvider + useTranslation integration', () => {
|
||||
it('FE-COMP-I18N-010: useTranslation returns t, language, and locale', () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }) })
|
||||
|
||||
let result: { language: string; locale: string; tResult: string } | null = null
|
||||
|
||||
function TestComponent() {
|
||||
const { t, language, locale } = useTranslation()
|
||||
result = { language, locale, tResult: t('common.loading') }
|
||||
return null
|
||||
}
|
||||
|
||||
render(
|
||||
React.createElement(TranslationProvider, null, React.createElement(TestComponent))
|
||||
)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.language).toBe('en')
|
||||
expect(result!.locale).toBe('en-US')
|
||||
expect(result!.tResult).toBeTruthy()
|
||||
expect(typeof result!.tResult).toBe('string')
|
||||
})
|
||||
|
||||
it('FE-COMP-I18N-011: t() with params substitutes {count} placeholders', () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }) })
|
||||
|
||||
let translated = ''
|
||||
|
||||
function TestComponent() {
|
||||
const { t } = useTranslation()
|
||||
translated = t('dashboard.subtitle.trips', { count: 5, archived: 2 })
|
||||
return null
|
||||
}
|
||||
|
||||
render(
|
||||
React.createElement(TranslationProvider, null, React.createElement(TestComponent))
|
||||
)
|
||||
|
||||
expect(translated).toContain('5')
|
||||
expect(translated).toContain('2')
|
||||
expect(translated).not.toContain('{count}')
|
||||
expect(translated).not.toContain('{archived}')
|
||||
})
|
||||
|
||||
it('FE-COMP-I18N-012: TranslationProvider sets document.documentElement.lang', () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ language: 'de' }) })
|
||||
|
||||
function TestComponent() {
|
||||
useTranslation()
|
||||
return null
|
||||
}
|
||||
|
||||
render(
|
||||
React.createElement(TranslationProvider, null, React.createElement(TestComponent))
|
||||
)
|
||||
|
||||
expect(document.documentElement.lang).toBe('de')
|
||||
})
|
||||
|
||||
it('FE-COMP-I18N-013: TranslationProvider sets dir=rtl for Arabic', () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ language: 'ar' }) })
|
||||
|
||||
function TestComponent() {
|
||||
useTranslation()
|
||||
return null
|
||||
}
|
||||
|
||||
render(
|
||||
React.createElement(TranslationProvider, null, React.createElement(TestComponent))
|
||||
)
|
||||
|
||||
expect(document.documentElement.dir).toBe('rtl')
|
||||
})
|
||||
|
||||
it('FE-COMP-I18N-014: TranslationProvider sets dir=ltr for non-RTL language', () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }) })
|
||||
|
||||
function TestComponent() {
|
||||
useTranslation()
|
||||
return null
|
||||
}
|
||||
|
||||
render(
|
||||
React.createElement(TranslationProvider, null, React.createElement(TestComponent))
|
||||
)
|
||||
|
||||
expect(document.documentElement.dir).toBe('ltr')
|
||||
})
|
||||
|
||||
it('FE-COMP-I18N-015: t() falls back to English for unknown language', () => {
|
||||
// Seed with a non-existent language to trigger fallback to English translations
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ language: 'xx' as any }) })
|
||||
|
||||
let translated = ''
|
||||
|
||||
function TestComponent() {
|
||||
const { t } = useTranslation()
|
||||
translated = t('common.loading')
|
||||
return null
|
||||
}
|
||||
|
||||
render(
|
||||
React.createElement(TranslationProvider, null, React.createElement(TestComponent))
|
||||
)
|
||||
|
||||
// Should fall back to English translation (non-empty, not the key itself if key exists in en)
|
||||
expect(typeof translated).toBe('string')
|
||||
expect(translated.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -193,4 +193,251 @@ describe('authStore', () => {
|
||||
expect(state.user).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-AUTH-010: completeMfaLogin success', () => {
|
||||
it('sets user, isAuthenticated, and calls connect', async () => {
|
||||
const user = buildUser();
|
||||
server.use(
|
||||
http.post('/api/auth/mfa/verify-login', () =>
|
||||
HttpResponse.json({ user, token: 'mfa-session-tok' })
|
||||
)
|
||||
);
|
||||
|
||||
await useAuthStore.getState().completeMfaLogin('mfa-tok', '123456');
|
||||
const state = useAuthStore.getState();
|
||||
|
||||
expect(state.user).toEqual(user);
|
||||
expect(state.isAuthenticated).toBe(true);
|
||||
expect(state.isLoading).toBe(false);
|
||||
expect(connect).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-AUTH-011: completeMfaLogin failure', () => {
|
||||
it('sets error and remains unauthenticated', async () => {
|
||||
server.use(
|
||||
http.post('/api/auth/mfa/verify-login', () =>
|
||||
HttpResponse.json({ error: 'Invalid code' }, { status: 401 })
|
||||
)
|
||||
);
|
||||
|
||||
await expect(
|
||||
useAuthStore.getState().completeMfaLogin('mfa-tok', '000000')
|
||||
).rejects.toThrow();
|
||||
|
||||
const state = useAuthStore.getState();
|
||||
expect(state.error).toBeTruthy();
|
||||
expect(state.isAuthenticated).toBe(false);
|
||||
expect(state.isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-AUTH-012: register failure', () => {
|
||||
it('sets error on registration failure', async () => {
|
||||
server.use(
|
||||
http.post('/api/auth/register', () =>
|
||||
HttpResponse.json({ error: 'Email taken' }, { status: 400 })
|
||||
)
|
||||
);
|
||||
|
||||
await expect(
|
||||
useAuthStore.getState().register('u', 'e@e.com', 'pw')
|
||||
).rejects.toThrow();
|
||||
|
||||
const state = useAuthStore.getState();
|
||||
expect(state.error).toBe('Email taken');
|
||||
expect(state.isAuthenticated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-AUTH-013: loadUser silent mode', () => {
|
||||
it('does not toggle isLoading when silent: true', async () => {
|
||||
const user = buildUser();
|
||||
server.use(
|
||||
http.get('/api/auth/me', () => HttpResponse.json({ user }))
|
||||
);
|
||||
|
||||
useAuthStore.setState({ isLoading: false });
|
||||
|
||||
// isLoading should remain false immediately after calling (silent mode)
|
||||
const loadPromise = useAuthStore.getState().loadUser({ silent: true });
|
||||
expect(useAuthStore.getState().isLoading).toBe(false);
|
||||
|
||||
await loadPromise;
|
||||
const state = useAuthStore.getState();
|
||||
expect(state.isAuthenticated).toBe(true);
|
||||
expect(state.isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-AUTH-014: loadUser network error (non-401)', () => {
|
||||
it('preserves auth state on network error', async () => {
|
||||
server.use(
|
||||
http.get('/api/auth/me', () =>
|
||||
HttpResponse.json({ error: 'Server error' }, { status: 500 })
|
||||
)
|
||||
);
|
||||
|
||||
useAuthStore.setState({ user: buildUser(), isAuthenticated: true });
|
||||
|
||||
await useAuthStore.getState().loadUser();
|
||||
const state = useAuthStore.getState();
|
||||
|
||||
expect(state.isAuthenticated).toBe(true);
|
||||
expect(state.isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-AUTH-015: updateMapsKey', () => {
|
||||
it('updates user maps_api_key', async () => {
|
||||
server.use(
|
||||
http.put('/api/auth/me/maps-key', () =>
|
||||
HttpResponse.json({ success: true })
|
||||
)
|
||||
);
|
||||
|
||||
useAuthStore.setState({ user: buildUser() });
|
||||
|
||||
await useAuthStore.getState().updateMapsKey('my-key');
|
||||
expect(useAuthStore.getState().user?.maps_api_key).toBe('my-key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-AUTH-016: updateMapsKey with null clears key', () => {
|
||||
it('sets maps_api_key to null', async () => {
|
||||
server.use(
|
||||
http.put('/api/auth/me/maps-key', () =>
|
||||
HttpResponse.json({ success: true })
|
||||
)
|
||||
);
|
||||
|
||||
useAuthStore.setState({ user: buildUser({ maps_api_key: 'old-key' }) });
|
||||
|
||||
await useAuthStore.getState().updateMapsKey(null);
|
||||
expect(useAuthStore.getState().user?.maps_api_key).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-AUTH-017: updateApiKeys', () => {
|
||||
it('updates user with returned data', async () => {
|
||||
const updatedUser = buildUser({ username: 'apiuser' });
|
||||
server.use(
|
||||
http.put('/api/auth/me/api-keys', () =>
|
||||
HttpResponse.json({ user: updatedUser })
|
||||
)
|
||||
);
|
||||
|
||||
useAuthStore.setState({ user: buildUser() });
|
||||
|
||||
await useAuthStore.getState().updateApiKeys({ some_api_key: 'val' });
|
||||
expect(useAuthStore.getState().user).toEqual(updatedUser);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-AUTH-018: updateProfile', () => {
|
||||
it('updates user profile', async () => {
|
||||
const updatedUser = buildUser({ username: 'updated' });
|
||||
server.use(
|
||||
http.put('/api/auth/me/settings', () =>
|
||||
HttpResponse.json({ user: updatedUser })
|
||||
)
|
||||
);
|
||||
|
||||
useAuthStore.setState({ user: buildUser() });
|
||||
|
||||
await useAuthStore.getState().updateProfile({ username: 'updated' });
|
||||
expect(useAuthStore.getState().user?.username).toBe('updated');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-AUTH-019: setDemoMode(true)', () => {
|
||||
it('sets demoMode and localStorage', () => {
|
||||
useAuthStore.getState().setDemoMode(true);
|
||||
expect(useAuthStore.getState().demoMode).toBe(true);
|
||||
expect(localStorage.getItem('demo_mode')).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-AUTH-020: setDemoMode(false)', () => {
|
||||
it('clears demoMode and localStorage', () => {
|
||||
localStorage.setItem('demo_mode', 'true');
|
||||
useAuthStore.getState().setDemoMode(false);
|
||||
expect(useAuthStore.getState().demoMode).toBe(false);
|
||||
expect(localStorage.getItem('demo_mode')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-AUTH-021: demoLogin success', () => {
|
||||
it('authenticates and sets demoMode', async () => {
|
||||
const user = buildUser();
|
||||
server.use(
|
||||
http.post('/api/auth/demo-login', () =>
|
||||
HttpResponse.json({ user, token: 'tok' })
|
||||
)
|
||||
);
|
||||
|
||||
await useAuthStore.getState().demoLogin();
|
||||
const state = useAuthStore.getState();
|
||||
|
||||
expect(state.isAuthenticated).toBe(true);
|
||||
expect(state.demoMode).toBe(true);
|
||||
expect(state.isLoading).toBe(false);
|
||||
expect(connect).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-AUTH-022: simple setters', () => {
|
||||
it('updates devMode, hasMapsKey, serverTimezone, appRequireMfa, tripRemindersEnabled', () => {
|
||||
const { setDevMode, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } =
|
||||
useAuthStore.getState();
|
||||
|
||||
setDevMode(true);
|
||||
expect(useAuthStore.getState().devMode).toBe(true);
|
||||
|
||||
setHasMapsKey(true);
|
||||
expect(useAuthStore.getState().hasMapsKey).toBe(true);
|
||||
|
||||
setServerTimezone('Europe/Berlin');
|
||||
expect(useAuthStore.getState().serverTimezone).toBe('Europe/Berlin');
|
||||
|
||||
setAppRequireMfa(true);
|
||||
expect(useAuthStore.getState().appRequireMfa).toBe(true);
|
||||
|
||||
setTripRemindersEnabled(true);
|
||||
expect(useAuthStore.getState().tripRemindersEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-AUTH-023: deleteAvatar', () => {
|
||||
it('sets avatar_url to null', async () => {
|
||||
server.use(
|
||||
http.delete('/api/auth/avatar', () =>
|
||||
HttpResponse.json({ success: true })
|
||||
)
|
||||
);
|
||||
|
||||
useAuthStore.setState({ user: buildUser({ avatar_url: '/uploads/avatar.png' }) });
|
||||
|
||||
await useAuthStore.getState().deleteAvatar();
|
||||
expect(useAuthStore.getState().user?.avatar_url).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-AUTH-UPLOAD: uploadAvatar', () => {
|
||||
it('updates avatar_url from response', async () => {
|
||||
server.use(
|
||||
http.post('/api/auth/avatar', () =>
|
||||
HttpResponse.json({ avatar_url: '/uploads/avatar-new.png' })
|
||||
)
|
||||
);
|
||||
|
||||
useAuthStore.setState({ user: buildUser() });
|
||||
|
||||
const file = new File(['x'], 'avatar.png', { type: 'image/png' });
|
||||
const result = await useAuthStore.getState().uploadAvatar(file);
|
||||
|
||||
expect(result.avatar_url).toBe('/uploads/avatar-new.png');
|
||||
expect(useAuthStore.getState().user?.avatar_url).toBe('/uploads/avatar-new.png');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -131,4 +131,221 @@ describe('inAppNotificationStore', () => {
|
||||
expect(state.unreadCount).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-NOTIF-007: fetchNotifications early-return when already loading', () => {
|
||||
it('does not fetch when isLoading is true', async () => {
|
||||
useInAppNotificationStore.setState({ isLoading: true });
|
||||
|
||||
await useInAppNotificationStore.getState().fetchNotifications();
|
||||
const state = useInAppNotificationStore.getState();
|
||||
|
||||
expect(state.notifications).toEqual([]);
|
||||
expect(state.isLoading).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-NOTIF-008: fetchNotifications(reset=true) resets existing list', () => {
|
||||
it('replaces seeded notifications with fresh data', async () => {
|
||||
// Seed store with 3 notifications
|
||||
useInAppNotificationStore.setState({
|
||||
notifications: [
|
||||
{ ...buildRawNotif({ id: 901 }), title_params: {}, text_params: {}, is_read: false },
|
||||
{ ...buildRawNotif({ id: 902 }), title_params: {}, text_params: {}, is_read: false },
|
||||
{ ...buildRawNotif({ id: 903 }), title_params: {}, text_params: {}, is_read: false },
|
||||
] as never,
|
||||
total: 3,
|
||||
});
|
||||
|
||||
await useInAppNotificationStore.getState().fetchNotifications(true);
|
||||
const state = useInAppNotificationStore.getState();
|
||||
|
||||
// Should not contain seeded IDs
|
||||
expect(state.notifications.find(n => n.id === 901)).toBeUndefined();
|
||||
expect(state.notifications.find(n => n.id === 902)).toBeUndefined();
|
||||
expect(state.notifications.find(n => n.id === 903)).toBeUndefined();
|
||||
// Should contain data from MSW (IDs 1-20)
|
||||
expect(state.notifications.length).toBe(20);
|
||||
expect(state.isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-NOTIF-009: hasMore is set correctly', () => {
|
||||
it('hasMore is true when more items exist, false when all loaded', async () => {
|
||||
// Default MSW returns 25 total, 20 per page
|
||||
await useInAppNotificationStore.getState().fetchNotifications(true);
|
||||
expect(useInAppNotificationStore.getState().hasMore).toBe(true);
|
||||
|
||||
// Second page: offset=20, returns 5 items, total=25 => 25 >= 25 => hasMore=false
|
||||
await useInAppNotificationStore.getState().fetchNotifications();
|
||||
expect(useInAppNotificationStore.getState().hasMore).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-NOTIF-010: fetchUnreadCount updates unreadCount', () => {
|
||||
it('sets unreadCount from server response', async () => {
|
||||
useInAppNotificationStore.setState({ unreadCount: 0 });
|
||||
|
||||
await useInAppNotificationStore.getState().fetchUnreadCount();
|
||||
expect(useInAppNotificationStore.getState().unreadCount).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-NOTIF-011: markUnread(id)', () => {
|
||||
it('sets is_read to false and increments unreadCount', async () => {
|
||||
useInAppNotificationStore.setState({
|
||||
notifications: [{ ...buildRawNotif({ id: 50, is_read: 1 }), title_params: {}, text_params: {}, is_read: true }] as never,
|
||||
unreadCount: 0,
|
||||
});
|
||||
|
||||
await useInAppNotificationStore.getState().markUnread(50);
|
||||
const state = useInAppNotificationStore.getState();
|
||||
|
||||
expect(state.notifications.find(n => n.id === 50)?.is_read).toBe(false);
|
||||
expect(state.unreadCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-NOTIF-012: markAllRead()', () => {
|
||||
it('marks all notifications as read and sets unreadCount to 0', async () => {
|
||||
useInAppNotificationStore.setState({
|
||||
notifications: [
|
||||
{ ...buildRawNotif({ id: 60 }), title_params: {}, text_params: {}, is_read: false },
|
||||
{ ...buildRawNotif({ id: 61 }), title_params: {}, text_params: {}, is_read: false },
|
||||
{ ...buildRawNotif({ id: 62 }), title_params: {}, text_params: {}, is_read: false },
|
||||
] as never,
|
||||
unreadCount: 3,
|
||||
});
|
||||
|
||||
await useInAppNotificationStore.getState().markAllRead();
|
||||
const state = useInAppNotificationStore.getState();
|
||||
|
||||
expect(state.notifications.every(n => n.is_read === true)).toBe(true);
|
||||
expect(state.unreadCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-NOTIF-013: deleteNotification removes unread item and decrements counts', () => {
|
||||
it('removes notification and decrements total and unreadCount', async () => {
|
||||
useInAppNotificationStore.setState({
|
||||
notifications: [{ ...buildRawNotif({ id: 5 }), title_params: {}, text_params: {}, is_read: false }] as never,
|
||||
total: 3,
|
||||
unreadCount: 1,
|
||||
});
|
||||
|
||||
await useInAppNotificationStore.getState().deleteNotification(5);
|
||||
const state = useInAppNotificationStore.getState();
|
||||
|
||||
expect(state.notifications.find(n => n.id === 5)).toBeUndefined();
|
||||
expect(state.total).toBe(2);
|
||||
expect(state.unreadCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-NOTIF-014: deleteNotification on read item does not decrement unreadCount', () => {
|
||||
it('decrements total but not unreadCount', async () => {
|
||||
useInAppNotificationStore.setState({
|
||||
notifications: [{ ...buildRawNotif({ id: 6, is_read: 1 }), title_params: {}, text_params: {}, is_read: true }] as never,
|
||||
total: 2,
|
||||
unreadCount: 0,
|
||||
});
|
||||
|
||||
await useInAppNotificationStore.getState().deleteNotification(6);
|
||||
const state = useInAppNotificationStore.getState();
|
||||
|
||||
expect(state.total).toBe(1);
|
||||
expect(state.unreadCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-NOTIF-015: deleteAll clears all state', () => {
|
||||
it('resets notifications, total, unreadCount, and hasMore', async () => {
|
||||
useInAppNotificationStore.setState({
|
||||
notifications: [
|
||||
{ ...buildRawNotif({ id: 70 }), title_params: {}, text_params: {}, is_read: false },
|
||||
{ ...buildRawNotif({ id: 71 }), title_params: {}, text_params: {}, is_read: false },
|
||||
] as never,
|
||||
total: 2,
|
||||
unreadCount: 2,
|
||||
hasMore: true,
|
||||
});
|
||||
|
||||
await useInAppNotificationStore.getState().deleteAll();
|
||||
const state = useInAppNotificationStore.getState();
|
||||
|
||||
expect(state.notifications).toEqual([]);
|
||||
expect(state.total).toBe(0);
|
||||
expect(state.unreadCount).toBe(0);
|
||||
expect(state.hasMore).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-NOTIF-016: respondToBoolean updates notification', () => {
|
||||
it('updates response and is_read from server', async () => {
|
||||
useInAppNotificationStore.setState({
|
||||
notifications: [{
|
||||
...buildRawNotif({ id: 10, type: 'boolean' }),
|
||||
title_params: {},
|
||||
text_params: {},
|
||||
is_read: false,
|
||||
}] as never,
|
||||
unreadCount: 1,
|
||||
});
|
||||
|
||||
await useInAppNotificationStore.getState().respondToBoolean(10, 'positive');
|
||||
const state = useInAppNotificationStore.getState();
|
||||
|
||||
const notif = state.notifications.find(n => n.id === 10);
|
||||
expect(notif?.response).toBe('positive');
|
||||
expect(notif?.is_read).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-NOTIF-017: normalizeNotification coerces stringified params', () => {
|
||||
it('parses JSON string params into objects', () => {
|
||||
const raw = buildRawNotif({
|
||||
id: 200,
|
||||
title_params: '{"trip":"Rome"}',
|
||||
text_params: '{"user":"alice"}',
|
||||
});
|
||||
|
||||
useInAppNotificationStore.getState().handleNewNotification(raw as never);
|
||||
const notif = useInAppNotificationStore.getState().notifications.find(n => n.id === 200);
|
||||
|
||||
expect(notif?.title_params).toEqual({ trip: 'Rome' });
|
||||
expect(notif?.text_params).toEqual({ user: 'alice' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-NOTIF-018: normalizeNotification handles already-parsed params', () => {
|
||||
it('stores object params without error', () => {
|
||||
const raw = buildRawNotif({
|
||||
id: 201,
|
||||
title_params: {},
|
||||
text_params: { key: 'value' },
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
useInAppNotificationStore.getState().handleNewNotification(raw as never);
|
||||
}).not.toThrow();
|
||||
|
||||
const notif = useInAppNotificationStore.getState().notifications.find(n => n.id === 201);
|
||||
expect(notif?.title_params).toEqual({});
|
||||
expect(notif?.text_params).toEqual({ key: 'value' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-NOTIF-019: fetchUnreadCount is best-effort', () => {
|
||||
it('does not throw on server error and preserves state', async () => {
|
||||
useInAppNotificationStore.setState({ unreadCount: 3 });
|
||||
|
||||
server.use(
|
||||
http.get('/api/notifications/in-app/unread-count', () => {
|
||||
return new HttpResponse(null, { status: 500 });
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(useInAppNotificationStore.getState().fetchUnreadCount()).resolves.not.toThrow();
|
||||
expect(useInAppNotificationStore.getState().unreadCount).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user