diff --git a/vue/src/__tests__/httpAuthInterceptor.spec.js b/vue/src/__tests__/httpAuthInterceptor.spec.js new file mode 100644 index 0000000..8aae17b --- /dev/null +++ b/vue/src/__tests__/httpAuthInterceptor.spec.js @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { useFeeds } from '../composables/useFeeds' + +// router/index.js's route definitions lazily import(...) the view components, +// which importing the real module would still be safe to do (the imports +// themselves aren't eagerly evaluated) — but mocking it keeps this test from +// depending on the app's actual routes and lets currentRoute/push be +// controlled directly. +vi.mock('../router', () => ({ + default: { + currentRoute: { value: { name: 'feeds' } }, + push: vi.fn(), + }, +})) + +import router from '../router' +import { handleUnauthorizedResponse } from '../httpAuthInterceptor' + +describe('handleUnauthorizedResponse', () => { + const { feedFilter } = useFeeds() + + beforeEach(() => { + localStorage.setItem('user-token', 'abc123') + localStorage.setItem('user-id', '7') + localStorage.setItem('feedFilter', JSON.stringify(['Feed A'])) + feedFilter.value = ['Feed A'] + router.currentRoute.value.name = 'feeds' + router.push.mockClear() + }) + + it('clears the session and the feed filter, and redirects to login, on a 401 from a non-login route', async () => { + const error = { response: { status: 401 } } + + await expect(handleUnauthorizedResponse(error)).rejects.toBe(error) + + expect(localStorage.getItem('user-token')).toBeNull() + expect(localStorage.getItem('user-id')).toBeNull() + expect(localStorage.getItem('feedFilter')).toBeNull() + expect(feedFilter.value).toBeNull() + expect(router.push).toHaveBeenCalledWith({ name: 'login' }) + }) + + it('still clears the session on a 401 but does not push again when already on the login route', async () => { + router.currentRoute.value.name = 'login' + const error = { response: { status: 401 } } + + await expect(handleUnauthorizedResponse(error)).rejects.toBe(error) + + expect(localStorage.getItem('user-token')).toBeNull() + expect(router.push).not.toHaveBeenCalled() + }) + + it('leaves the session and filter untouched, and still rejects, on a non-401 error response', async () => { + const error = { response: { status: 500 } } + + await expect(handleUnauthorizedResponse(error)).rejects.toBe(error) + + expect(localStorage.getItem('user-token')).toBe('abc123') + expect(localStorage.getItem('feedFilter')).toBe(JSON.stringify(['Feed A'])) + expect(feedFilter.value).toEqual(['Feed A']) + expect(router.push).not.toHaveBeenCalled() + }) + + it('leaves the session untouched and still rejects on an error with no response (e.g. a network error)', async () => { + const error = new Error('network') + + await expect(handleUnauthorizedResponse(error)).rejects.toBe(error) + + expect(localStorage.getItem('user-token')).toBe('abc123') + expect(router.push).not.toHaveBeenCalled() + }) +}) diff --git a/vue/src/components/AppNav.vue b/vue/src/components/AppNav.vue index ea1cfcb..45848fc 100644 --- a/vue/src/components/AppNav.vue +++ b/vue/src/components/AppNav.vue @@ -2,29 +2,27 @@ import { ref, computed, onMounted, onUnmounted } from 'vue' import { RouterLink, useRouter, useRoute } from 'vue-router' import { useFeeds, logout as logoutSession } from '@/composables/useFeeds' +import FeedFilter from './modal/FeedFilter.vue' const router = useRouter() const route = useRoute() const { sync, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, setFeedFilter, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, lastProgrammaticScroll, isSyncing } = useFeeds() const headerRef = ref(null) +const filterBtnRef = ref(null) +const filterModalOpen = ref(false) -// Native - - - + + + + +