feat: typed API client (useApi) and session store (useSession) with the global 401, unverified-403, 429 and offline handling from states.md
This commit is contained in:
1 parent
eed661bb24
commit
12faab7f92
6 files changed
+488
-6
No files matched your search
@@ -0,0 +1,121 @@
|
||||
Feature: Typed API client and session store with global 401, unverified-403, 429 and offline handling
|
||||
|
||||
As the maintainer of RenewCMS
|
||||
I want useApi to expose one generic authenticated request method that every future screen call
|
||||
site builds on, that turns a mid-session 401 into a redirect to /login with the expired reason
|
||||
and clears the session, turns a 403 from a known-unverified session into a redirect to
|
||||
/register/pending, surfaces the Retry-After seconds from a 429 so screens can show a countdown,
|
||||
and marks the app offline on a network failure
|
||||
So that later screen slices (login, articles, editor, ...) get consistent global session and
|
||||
connectivity handling instead of each reimplementing 401/403/429/offline handling ad hoc
|
||||
|
||||
Background:
|
||||
Given useApi exports a generic request(path, options) method, used by getMe and by every future
|
||||
endpoint call, that always sends credentials: 'include' and parses a JSON response body on
|
||||
success
|
||||
And a non-2xx response makes request(...) throw an ApiError carrying the numeric status; a 429
|
||||
response also carries retryAfter, the integer number of seconds parsed from the response's
|
||||
Retry-After header, or undefined if the header is absent or not a plain integer
|
||||
And a fetch that rejects instead of resolving (no connectivity, DNS failure, the request never
|
||||
reaches a server) makes request(...) throw a NetworkError instead of an ApiError, so callers
|
||||
can tell "the server answered with an error" apart from "the request never got an answer" with
|
||||
instanceof
|
||||
And useApi exports a shared reactive isOffline ref, module-scoped so every caller of useApi() and
|
||||
every component observes the same value: a NetworkError sets it true; it is also true whenever
|
||||
navigator.onLine is false; a request that completes (2xx or a non-network ApiError) while
|
||||
navigator.onLine is true sets it back to false
|
||||
And getMe() calls request('/v1/auth/me', { onUnauthorized: 'passthrough' }): a 401 from this one
|
||||
call still throws a plain ApiError with status 401, exactly as before, and does not trigger the
|
||||
redirect below, because this call only ever runs as part of session.check() during router
|
||||
navigation (features/nuxt_ui_shell_foundation.feature), and the router's own guard already
|
||||
redirects to /login?redirect=<target>&reason=expired for a "no session" boot result
|
||||
And every other call to request(...) uses the default onUnauthorized: 'redirect': on a 401,
|
||||
before the ApiError reaches the caller, useApi calls the session store's clear() action (session
|
||||
= null, status stays 'ready') and navigates the router to
|
||||
/login?redirect=<currentPath>&reason=expired, where <currentPath> is
|
||||
router.currentRoute.value.fullPath at the moment the 401 is received
|
||||
And on a 403, useApi reads the session store: if it holds a session whose verified is false, this
|
||||
is the global "unverified account" case from docs/design/routes.md and states.md, so useApi
|
||||
navigates the router to /register/pending; the ApiError still reaches the caller as a plain 403,
|
||||
and the session store is left untouched, since it already correctly records verified: false; any
|
||||
other 403 (no session, or a session whose verified is true) is not the global case: useApi does
|
||||
nothing extra and the ApiError propagates to the caller unchanged, for the screen to interpret
|
||||
(wrong-credentials at login, already-verified at cancel-registration, an expired verify or reset
|
||||
link, and registration-closed are all screen-level, out of scope here)
|
||||
And the session store gains a clear() action (session = null, status = 'ready', independent of
|
||||
check()/retry()) used by the global 401 handling above
|
||||
|
||||
Scenario: A successful authenticated request returns the parsed body
|
||||
Given a mocked fetch that resolves 200 with JSON body {"id": 5}
|
||||
When useApi().request('/v1/article/5') is called
|
||||
Then the returned value is {"id": 5}
|
||||
And isOffline is false
|
||||
|
||||
Scenario: A 401 received mid-session redirects to login with the expired reason and clears the session
|
||||
Given the session store holds session {"verified": true} and status "ready"
|
||||
And the current route is /article/12/edit
|
||||
And a mocked fetch that resolves 401
|
||||
When useApi().request('/v1/article/12') is called
|
||||
Then the call rejects with an ApiError whose status is 401
|
||||
And the session store's session becomes null and its status stays "ready"
|
||||
And the router navigates to "/login?redirect=/article/12/edit&reason=expired"
|
||||
|
||||
Scenario: The boot call to getMe does not trigger the global 401 redirect
|
||||
Given the session store holds session null and status "checking"
|
||||
And a mocked fetch that resolves 401
|
||||
When useApi().getMe() is called directly, outside of router navigation
|
||||
Then the call rejects with an ApiError whose status is 401
|
||||
And the router does not navigate anywhere
|
||||
|
||||
Scenario: A 403 while the session is known to be unverified redirects to the pending page
|
||||
Given the session store holds session {"verified": false} and status "ready"
|
||||
And a mocked fetch that resolves 403
|
||||
When useApi().request('/v1/article/') is called
|
||||
Then the call rejects with an ApiError whose status is 403
|
||||
And the router navigates to "/register/pending"
|
||||
And the session store's session is still {"verified": false}
|
||||
|
||||
Scenario Outline: A 403 that is not the global unverified case passes through untouched
|
||||
Given the session store holds session <session> and status "ready"
|
||||
And a mocked fetch that resolves 403
|
||||
When useApi().request('/v1/some/endpoint') is called
|
||||
Then the call rejects with an ApiError whose status is 403
|
||||
And the router does not navigate anywhere
|
||||
And the session store is unchanged
|
||||
|
||||
Examples:
|
||||
| session |
|
||||
| null |
|
||||
| {"verified": true} |
|
||||
|
||||
Scenario: A 429 on a rate-limited endpoint exposes the Retry-After seconds
|
||||
Given a mocked fetch that resolves 429 with a Retry-After header of "30"
|
||||
When useApi().request('/v1/auth/login', { method: 'POST' }) is called
|
||||
Then the call rejects with an ApiError whose status is 429 and whose retryAfter is 30
|
||||
|
||||
Scenario: A 429 with no Retry-After header exposes an undefined retryAfter
|
||||
Given a mocked fetch that resolves 429 with no Retry-After header
|
||||
When useApi().request('/v1/auth/password-reset', { method: 'POST' }) is called
|
||||
Then the call rejects with an ApiError whose status is 429 and whose retryAfter is undefined
|
||||
|
||||
Scenario: A network failure throws a NetworkError and marks the app offline
|
||||
Given navigator.onLine is true
|
||||
And a mocked fetch that rejects with a TypeError, as a real fetch does when the network is
|
||||
unreachable
|
||||
When useApi().request('/v1/article/') is called
|
||||
Then the call rejects with a NetworkError, not an ApiError
|
||||
And isOffline becomes true
|
||||
|
||||
Scenario: A later successful request clears the offline flag
|
||||
Given isOffline is true from a previous network failure
|
||||
And navigator.onLine is true
|
||||
And a mocked fetch that resolves 200 with JSON body {}
|
||||
When useApi().request('/v1/article/') is called
|
||||
Then isOffline becomes false
|
||||
|
||||
Scenario: The browser's own online/offline events also drive the shared flag
|
||||
Given isOffline is false
|
||||
When the browser fires a "offline" event on window
|
||||
Then isOffline becomes true
|
||||
When the browser fires a "online" event on window
|
||||
Then isOffline becomes false
|
||||
@@ -0,0 +1,76 @@
|
||||
# QA: Typed API client and session store with global 401, unverified-403, 429 and offline handling
|
||||
|
||||
This is a composable/store-only slice with no new screen and no new `/v1` handler, so there is no
|
||||
Go `httptest` procedure. Verification is entirely Vitest (`task test:web`), unit-testing
|
||||
`web/src/composables/useApi.ts` and `web/src/stores/session.ts` directly, plus a small check that
|
||||
the existing router guard suite still passes unmodified.
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Mock `global.fetch` per case (`vi.fn()`), install a Pinia instance (`createPinia()` /
|
||||
`setActivePinia`) before each test, and import the real `router` singleton from `@/router` so
|
||||
`request(...)`'s navigation calls are observable on `router.currentRoute`. No real backend, no
|
||||
httptest server, no live SMTP.
|
||||
|
||||
1. **Successful request returns parsed JSON and leaves isOffline false** — mock fetch resolving
|
||||
`{ ok: true, status: 200, json: async () => ({ id: 5 }) }`; call
|
||||
`useApi().request('/v1/article/5')`; assert it resolves to `{ id: 5 }`, assert `fetch` was called
|
||||
with `credentials: 'include'`, and assert the exported `isOffline` ref is `false`.
|
||||
2. **401 mid-session redirects and clears the session** — seed the session store with
|
||||
`session = { verified: true }`, `status = 'ready'`; navigate the router to `/article/12/edit`
|
||||
first so `router.currentRoute.value.fullPath` matches; mock fetch resolving status 401; call
|
||||
`useApi().request('/v1/article/12')`; assert the promise rejects with an `ApiError` whose
|
||||
`status === 401`; assert `session.session === null` and `session.status === 'ready'`; assert
|
||||
`router.currentRoute.value.fullPath` is
|
||||
`/login?redirect=%2Farticle%2F12%2Fedit&reason=expired` (or the equivalent decoded query object).
|
||||
3. **getMe's own 401 does not trigger the global redirect** — seed
|
||||
`session = null, status = 'checking'`; mock fetch resolving status 401; record
|
||||
`router.currentRoute.value.fullPath` before the call; call `useApi().getMe()`; assert it rejects
|
||||
with `ApiError` status 401; assert the router's current route is unchanged from before the call.
|
||||
4. **403 with a known-unverified session redirects to /register/pending** — seed
|
||||
`session = { verified: false }, status = 'ready'`; mock fetch resolving status 403; call
|
||||
`useApi().request('/v1/article/')`; assert the promise rejects with `ApiError` status 403; assert
|
||||
`router.currentRoute.value.path === '/register/pending'`; assert
|
||||
`session.session` is still `{ verified: false }`.
|
||||
5. **403 with no session, or a verified session, passes through untouched** — two cases,
|
||||
`session = null` and `session = { verified: true }`, both `status = 'ready'`; mock fetch resolving
|
||||
status 403; navigate the router to a known path first; call `useApi().request(...)`; assert the
|
||||
promise rejects with `ApiError` status 403; assert the router's current route did not change;
|
||||
assert the session store's state object is unchanged (`toEqual` the pre-call snapshot).
|
||||
6. **429 exposes retryAfter parsed from the Retry-After header** — mock fetch resolving
|
||||
`{ ok: false, status: 429, headers: new Headers({ 'Retry-After': '30' }) }`; call
|
||||
`useApi().request('/v1/auth/login', { method: 'POST' })`; assert the rejection is an `ApiError`
|
||||
with `status === 429` and `retryAfter === 30`.
|
||||
7. **429 with no Retry-After header exposes retryAfter as undefined** — same as above with no header
|
||||
set; assert `retryAfter === undefined`.
|
||||
8. **Network failure throws NetworkError, not ApiError, and sets isOffline true** — set
|
||||
`Object.defineProperty(navigator, 'onLine', { value: true, configurable: true })`; mock fetch to
|
||||
`mockRejectedValue(new TypeError('Failed to fetch'))`; call `useApi().request('/v1/article/')`;
|
||||
assert the rejection is instanceof `NetworkError` and `not instanceof ApiError`; assert `isOffline`
|
||||
is `true` after the rejection settles.
|
||||
9. **A later successful request clears isOffline** — starting from `isOffline === true` (reuse the
|
||||
state from test 8, or set it directly if the module exposes a test seam), keep
|
||||
`navigator.onLine === true`, mock fetch resolving 200; call `useApi().request('/v1/article/')`;
|
||||
assert `isOffline` becomes `false` after the call resolves.
|
||||
10. **window online/offline events drive isOffline directly** — dispatch
|
||||
`window.dispatchEvent(new Event('offline'))`; assert `isOffline === true`; dispatch
|
||||
`window.dispatchEvent(new Event('online'))`; assert `isOffline === false`. If `useApi`'s offline
|
||||
tracking is implemented as a composable that needs to be "activated" (e.g. listeners attached on
|
||||
first `useApi()` call or app mount), call `useApi()` once before dispatching the events.
|
||||
11. **Regression: existing getMe/session/router-guard suites stay green** — run
|
||||
`web/src/router/__tests__/routerGuard.spec.ts` (and any existing `useApi`/`session` spec files)
|
||||
unmodified alongside the new tests in the same `task test:web` run; assert no existing test's
|
||||
assertions changed and all still pass. This is not a new scenario to author, just a check that the
|
||||
new request()/isOffline/clear() additions don't regress boot, session caching or the
|
||||
public/session/verified/any redirect matrix already covered by
|
||||
`features/nuxt_ui_shell_foundation.feature`.
|
||||
|
||||
## Manual verification
|
||||
|
||||
- **Real network offline test** — disable the machine's network adapter (or use browser devtools'
|
||||
"Offline" throttling) against a `bun dev` instance, trigger a real authenticated call, and confirm
|
||||
`isOffline` flips and a consuming banner-style UI would show, since Vitest only simulates a
|
||||
rejected `fetch`, never a real dropped connection.
|
||||
- **429 countdown UI** — this slice only proves `useApi` surfaces `retryAfter`; the actual countdown
|
||||
display (`useRetryAfter`, per `docs/design/components.md`) belongs to the login and
|
||||
password-reset screen slices and is out of scope here.
|
||||
@@ -0,0 +1,178 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import router from '@/router'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { ApiError, NetworkError, isOffline, useApi } from '@/composables/useApi'
|
||||
|
||||
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: { get: (name: string) => headers[name] ?? null },
|
||||
json: async () => body,
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
function setOnline(value: boolean) {
|
||||
Object.defineProperty(navigator, 'onLine', { configurable: true, value })
|
||||
}
|
||||
|
||||
async function expectApiError(call: Promise<unknown>, matcher: Record<string, unknown>) {
|
||||
await expect(call).rejects.toBeInstanceOf(ApiError)
|
||||
await expect(call).rejects.toMatchObject(matcher)
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
await flushPromises()
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
}
|
||||
|
||||
const fetchMock = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
fetchMock.mockReset()
|
||||
global.fetch = fetchMock as unknown as typeof fetch
|
||||
setOnline(true)
|
||||
isOffline.value = false
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
describe('useApi global 401/403/429/offline handling', () => {
|
||||
it('A successful authenticated request returns the parsed body', async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse(200, { id: 5 }))
|
||||
|
||||
const result = await useApi().request('/v1/article/5')
|
||||
|
||||
expect(result).toEqual({ id: 5 })
|
||||
expect(isOffline.value).toBe(false)
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/v1/article/5',
|
||||
expect.objectContaining({ credentials: 'include' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('A 401 received mid-session redirects to login with the expired reason and clears the session', async () => {
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true }
|
||||
session.status = 'ready'
|
||||
await router.push('/article/12/edit')
|
||||
fetchMock.mockResolvedValue(mockResponse(401))
|
||||
|
||||
const call = useApi().request('/v1/article/12')
|
||||
|
||||
await expectApiError(call, { status: 401 })
|
||||
|
||||
expect(session.session).toBeNull()
|
||||
expect(session.status).toBe('ready')
|
||||
await vi.waitFor(() =>
|
||||
expect(router.currentRoute.value.fullPath).toBe(
|
||||
'/login?redirect=/article/12/edit&reason=expired',
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it('The boot call to getMe does not trigger the global 401 redirect', async () => {
|
||||
const session = useSessionStore()
|
||||
session.session = null
|
||||
session.status = 'checking'
|
||||
await router.push('/login')
|
||||
const pathBefore = router.currentRoute.value.fullPath
|
||||
fetchMock.mockResolvedValue(mockResponse(401))
|
||||
|
||||
const call = useApi().getMe()
|
||||
|
||||
await expectApiError(call, { status: 401 })
|
||||
await settle()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(pathBefore)
|
||||
})
|
||||
|
||||
it('A 403 while the session is known to be unverified redirects to the pending page', async () => {
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: false }
|
||||
session.status = 'ready'
|
||||
fetchMock.mockResolvedValue(mockResponse(403))
|
||||
|
||||
const call = useApi().request('/v1/article/')
|
||||
|
||||
await expectApiError(call, { status: 403 })
|
||||
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
|
||||
expect(session.session).toEqual({ verified: false })
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ name: 'null', session: null },
|
||||
{ name: 'verified true', session: { verified: true } },
|
||||
])(
|
||||
'A 403 that is not the global unverified case passes through untouched ($name)',
|
||||
async ({ session: sessionValue }) => {
|
||||
const session = useSessionStore()
|
||||
session.session = sessionValue
|
||||
session.status = 'ready'
|
||||
await router.push('/login')
|
||||
const pathBefore = router.currentRoute.value.fullPath
|
||||
fetchMock.mockResolvedValue(mockResponse(403))
|
||||
|
||||
const call = useApi().request('/v1/some/endpoint')
|
||||
|
||||
await expectApiError(call, { status: 403 })
|
||||
await settle()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(pathBefore)
|
||||
expect(session.session).toEqual(sessionValue)
|
||||
},
|
||||
)
|
||||
|
||||
it('A 429 on a rate-limited endpoint exposes the Retry-After seconds', async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse(429, null, { 'Retry-After': '30' }))
|
||||
|
||||
const call = useApi().request('/v1/auth/login', { method: 'POST' })
|
||||
|
||||
await expectApiError(call, { status: 429, retryAfter: 30 })
|
||||
})
|
||||
|
||||
it('A 429 with no Retry-After header exposes an undefined retryAfter', async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse(429))
|
||||
|
||||
const call = useApi().request('/v1/auth/password-reset', { method: 'POST' })
|
||||
|
||||
await expectApiError(call, { status: 429, retryAfter: undefined })
|
||||
})
|
||||
|
||||
it('A network failure throws a NetworkError and marks the app offline', async () => {
|
||||
setOnline(true)
|
||||
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'))
|
||||
|
||||
const call = useApi().request('/v1/article/')
|
||||
|
||||
await expect(call).rejects.toBeInstanceOf(NetworkError)
|
||||
await expect(call).rejects.not.toBeInstanceOf(ApiError)
|
||||
expect(isOffline.value).toBe(true)
|
||||
})
|
||||
|
||||
it('A later successful request clears the offline flag', async () => {
|
||||
isOffline.value = true
|
||||
setOnline(true)
|
||||
fetchMock.mockResolvedValue(mockResponse(200, {}))
|
||||
|
||||
await useApi().request('/v1/article/')
|
||||
|
||||
expect(isOffline.value).toBe(false)
|
||||
})
|
||||
|
||||
it("The browser's own online/offline events also drive the shared flag", () => {
|
||||
isOffline.value = false
|
||||
|
||||
window.dispatchEvent(new Event('offline'))
|
||||
expect(isOffline.value).toBe(true)
|
||||
|
||||
window.dispatchEvent(new Event('online'))
|
||||
expect(isOffline.value).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,21 @@
|
||||
import { ref } from 'vue'
|
||||
import router from '@/router'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
retryAfter?: number
|
||||
|
||||
constructor(status: number) {
|
||||
constructor(status: number, retryAfter?: number) {
|
||||
super(`Request failed with status ${status}`)
|
||||
this.status = status
|
||||
this.retryAfter = retryAfter
|
||||
}
|
||||
}
|
||||
|
||||
export class NetworkError extends Error {
|
||||
constructor() {
|
||||
super('Network request failed')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,15 +23,85 @@ export interface MeResult {
|
||||
verified: boolean
|
||||
}
|
||||
|
||||
export type OnUnauthorized = 'redirect' | 'passthrough'
|
||||
|
||||
export interface RequestOptions extends RequestInit {
|
||||
onUnauthorized?: OnUnauthorized
|
||||
}
|
||||
|
||||
export const isOffline = ref(false)
|
||||
|
||||
function isNavigatorOnLine() {
|
||||
return typeof navigator === 'undefined' || navigator.onLine !== false
|
||||
}
|
||||
|
||||
function syncOfflineFlagWithNavigator() {
|
||||
isOffline.value = !isNavigatorOnLine()
|
||||
}
|
||||
|
||||
function parseRetryAfterSeconds(response: Response): number | undefined {
|
||||
const header = response.headers.get('Retry-After')
|
||||
if (header === null || !/^\d+$/.test(header)) {
|
||||
return undefined
|
||||
}
|
||||
return Number.parseInt(header, 10)
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('offline', () => {
|
||||
isOffline.value = true
|
||||
})
|
||||
window.addEventListener('online', () => {
|
||||
isOffline.value = false
|
||||
})
|
||||
}
|
||||
|
||||
export function useApi() {
|
||||
async function getMe(): Promise<MeResult> {
|
||||
const response = await fetch('/v1/auth/me', { credentials: 'include' })
|
||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { onUnauthorized = 'redirect', ...init } = options
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(path, { credentials: 'include', ...init })
|
||||
} catch {
|
||||
isOffline.value = true
|
||||
throw new NetworkError()
|
||||
}
|
||||
|
||||
syncOfflineFlagWithNavigator()
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && onUnauthorized === 'redirect') {
|
||||
const currentPath = router.currentRoute.value.fullPath
|
||||
useSessionStore().clear()
|
||||
router.push({ path: '/login', query: { redirect: currentPath, reason: 'expired' } })
|
||||
throw new ApiError(401)
|
||||
}
|
||||
|
||||
if (response.status === 403) {
|
||||
const session = useSessionStore()
|
||||
if (session.session?.verified === false) {
|
||||
router.push('/register/pending')
|
||||
}
|
||||
throw new ApiError(403)
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
throw new ApiError(429, parseRetryAfterSeconds(response))
|
||||
}
|
||||
|
||||
throw new ApiError(response.status)
|
||||
}
|
||||
const body = await response.json()
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
async function getMe(): Promise<MeResult> {
|
||||
const body = await request<{ is_verified: boolean }>('/v1/auth/me', {
|
||||
onUnauthorized: 'passthrough',
|
||||
})
|
||||
return { verified: Boolean(body.is_verified) }
|
||||
}
|
||||
|
||||
return { getMe }
|
||||
return { getMe, request }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
describe('session store clear action', () => {
|
||||
it('resets the session to null and the status to ready regardless of the previous state', () => {
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true }
|
||||
session.status = 'checking'
|
||||
|
||||
session.clear()
|
||||
|
||||
expect(session.session).toBeNull()
|
||||
expect(session.status).toBe('ready')
|
||||
})
|
||||
})
|
||||
@@ -46,5 +46,10 @@ export const useSessionStore = defineStore('session', () => {
|
||||
return check()
|
||||
}
|
||||
|
||||
return { status, session, check, retry }
|
||||
function clear() {
|
||||
session.value = null
|
||||
status.value = 'ready'
|
||||
}
|
||||
|
||||
return { status, session, check, retry, clear }
|
||||
})
|
||||
Reference in new issue
Block a user