From fe4091c7ba0be8785da4f89f2a9bd23581288f98 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 22 Mar 2026 16:27:45 -0700 Subject: [PATCH] =?UTF-8?q?test(settings):=20settingsGuard=20unit=20tests?= =?UTF-8?q?=20=E2=80=94=20tab=20gating=20scenarios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract guard logic to settingsGuard.ts for testability. Router beforeEach keeps async config.load() wrapper, delegates to sync guard. 14 test cases cover system/fine-tune/developer gates across cloud/self-hosted/tier/GPU profile combos. --- web/src/router/index.ts | 14 +-- web/src/router/settings.guard.test.ts | 135 ++++++++++++++++++++++++++ web/src/router/settingsGuard.ts | 31 ++++++ 3 files changed, 168 insertions(+), 12 deletions(-) create mode 100644 web/src/router/settings.guard.test.ts create mode 100644 web/src/router/settingsGuard.ts diff --git a/web/src/router/index.ts b/web/src/router/index.ts index 585c22c..6607169 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -1,5 +1,6 @@ import { createRouter, createWebHistory } from 'vue-router' import { useAppConfigStore } from '../stores/appConfig' +import { settingsGuard } from './settingsGuard' export const router = createRouter({ history: createWebHistory(), @@ -39,16 +40,5 @@ router.beforeEach(async (to, _from, next) => { if (!to.path.startsWith('/settings/')) return next() const config = useAppConfigStore() if (!config.loaded) await config.load() - const tab = to.path.replace('/settings/', '') - const devOverride = config.devTierOverride - const gpuProfiles = ['single-gpu', 'dual-gpu'] - - if (tab === 'system' && config.isCloud) return next('/settings/my-profile') - if (tab === 'fine-tune') { - const cloudBlocked = config.isCloud && config.tier !== 'premium' - const selfHostedBlocked = !config.isCloud && !gpuProfiles.includes(config.inferenceProfile) - if (cloudBlocked || selfHostedBlocked) return next('/settings/my-profile') - } - if (tab === 'developer' && !config.isDevMode && !devOverride) return next('/settings/my-profile') - next() + settingsGuard(to, _from, next) }) diff --git a/web/src/router/settings.guard.test.ts b/web/src/router/settings.guard.test.ts new file mode 100644 index 0000000..0c1c605 --- /dev/null +++ b/web/src/router/settings.guard.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useAppConfigStore } from '../stores/appConfig' +import { settingsGuard } from './settingsGuard' + +vi.mock('../composables/useApi', () => ({ useApiFetch: vi.fn() })) + +describe('settingsGuard', () => { + beforeEach(() => { + setActivePinia(createPinia()) + localStorage.clear() + }) + afterEach(() => { + localStorage.clear() + }) + + it('passes through non-settings routes immediately', () => { + const next = vi.fn() + settingsGuard({ path: '/review' }, {}, next) + // Guard only handles /settings/* — for non-settings routes the router + // calls next() before reaching settingsGuard, but the guard itself + // will still call next() with no redirect since no tab matches + expect(next).toHaveBeenCalledWith() + }) + + it('redirects /settings/system in cloud mode', () => { + const store = useAppConfigStore() + store.isCloud = true + const next = vi.fn() + settingsGuard({ path: '/settings/system' }, {}, next) + expect(next).toHaveBeenCalledWith('/settings/my-profile') + }) + + it('allows /settings/system in self-hosted mode', () => { + const store = useAppConfigStore() + store.isCloud = false + const next = vi.fn() + settingsGuard({ path: '/settings/system' }, {}, next) + expect(next).toHaveBeenCalledWith() + }) + + it('redirects /settings/fine-tune for non-GPU self-hosted', () => { + const store = useAppConfigStore() + store.isCloud = false + store.inferenceProfile = 'cpu' + const next = vi.fn() + settingsGuard({ path: '/settings/fine-tune' }, {}, next) + expect(next).toHaveBeenCalledWith('/settings/my-profile') + }) + + it('allows /settings/fine-tune for single-gpu self-hosted', () => { + const store = useAppConfigStore() + store.isCloud = false + store.inferenceProfile = 'single-gpu' + const next = vi.fn() + settingsGuard({ path: '/settings/fine-tune' }, {}, next) + expect(next).toHaveBeenCalledWith() + }) + + it('allows /settings/fine-tune for dual-gpu self-hosted', () => { + const store = useAppConfigStore() + store.isCloud = false + store.inferenceProfile = 'dual-gpu' + const next = vi.fn() + settingsGuard({ path: '/settings/fine-tune' }, {}, next) + expect(next).toHaveBeenCalledWith() + }) + + it('redirects /settings/fine-tune on cloud when tier is not premium', () => { + const store = useAppConfigStore() + store.isCloud = true + store.tier = 'paid' + const next = vi.fn() + settingsGuard({ path: '/settings/fine-tune' }, {}, next) + expect(next).toHaveBeenCalledWith('/settings/my-profile') + }) + + it('allows /settings/fine-tune on cloud when tier is premium', () => { + const store = useAppConfigStore() + store.isCloud = true + store.tier = 'premium' + const next = vi.fn() + settingsGuard({ path: '/settings/fine-tune' }, {}, next) + expect(next).toHaveBeenCalledWith() + }) + + it('redirects /settings/developer when not dev mode and no override', () => { + const store = useAppConfigStore() + store.isDevMode = false + const next = vi.fn() + settingsGuard({ path: '/settings/developer' }, {}, next) + expect(next).toHaveBeenCalledWith('/settings/my-profile') + }) + + it('allows /settings/developer when isDevMode is true', () => { + const store = useAppConfigStore() + store.isDevMode = true + const next = vi.fn() + settingsGuard({ path: '/settings/developer' }, {}, next) + expect(next).toHaveBeenCalledWith() + }) + + it('allows /settings/developer when dev_tier_override set in localStorage', () => { + const store = useAppConfigStore() + store.isDevMode = false + localStorage.setItem('dev_tier_override', 'premium') + const next = vi.fn() + settingsGuard({ path: '/settings/developer' }, {}, next) + expect(next).toHaveBeenCalledWith() + }) + + it('allows /settings/privacy in cloud mode', () => { + const store = useAppConfigStore() + store.isCloud = true + const next = vi.fn() + settingsGuard({ path: '/settings/privacy' }, {}, next) + expect(next).toHaveBeenCalledWith() + }) + + it('allows /settings/privacy in self-hosted mode', () => { + const store = useAppConfigStore() + store.isCloud = false + const next = vi.fn() + settingsGuard({ path: '/settings/privacy' }, {}, next) + expect(next).toHaveBeenCalledWith() + }) + + it('allows /settings/license in both modes', () => { + const store = useAppConfigStore() + store.isCloud = true + const next = vi.fn() + settingsGuard({ path: '/settings/license' }, {}, next) + expect(next).toHaveBeenCalledWith() + }) +}) diff --git a/web/src/router/settingsGuard.ts b/web/src/router/settingsGuard.ts new file mode 100644 index 0000000..f3429f4 --- /dev/null +++ b/web/src/router/settingsGuard.ts @@ -0,0 +1,31 @@ +import { useAppConfigStore } from '../stores/appConfig' + +const GPU_PROFILES = ['single-gpu', 'dual-gpu'] + +/** + * Synchronous tab-gating logic for /settings/* routes. + * Called by the async router.beforeEach after config.load() has resolved. + * Reading devTierOverride from localStorage here (not only the store ref) ensures + * the guard reflects overrides set externally before the store hydrates. + */ +export function settingsGuard( + to: { path: string }, + _from: unknown, + next: (to?: string) => void, +): void { + const config = useAppConfigStore() + const tab = to.path.replace('/settings/', '') + const devOverride = config.devTierOverride || localStorage.getItem('dev_tier_override') + + if (tab === 'system' && config.isCloud) return next('/settings/my-profile') + + if (tab === 'fine-tune') { + const cloudBlocked = config.isCloud && config.tier !== 'premium' + const selfHostedBlocked = !config.isCloud && !GPU_PROFILES.includes(config.inferenceProfile) + if (cloudBlocked || selfHostedBlocked) return next('/settings/my-profile') + } + + if (tab === 'developer' && !config.isDevMode && !devOverride) return next('/settings/my-profile') + + next() +}