fix(admin): scope remembered list state to the signed-in admin and recover from bad restored queries

- Store list queries under summer-admin.list:<admin id>:<controller>; me()
  and setCurrentUser set the owner, which removes other admins' keys and
  the unscoped keys of older builds; nothing is read or written without one
- clearUser removes every stored query, so logout, session expiry and an
  unrecoverable 401 (expireSession, now used by main.ts) all clear them
- A list request built from a restored query that fails for any reason
  (422, 500, network) forgets the query and loads the plain list once
- A failing URL query keeps the error and offers a reset button
  (backend::lang.list.reset_list, en and pl)
- Document remembered list state in docs/backend/lists-and-filters.md
- Rebuild modules/boardwalk/dist
This commit is contained in:
2026-10-09 23:53:19 +02:00
parent 5be98a0c83
commit cd25af0878
15 changed files with 594 additions and 75 deletions

View File

@@ -186,6 +186,9 @@
"backend::lang.list.records_per_page": {
"other": "Na stronę"
},
"backend::lang.list.reset_list": {
"other": "Wyczyść wyszukiwanie, filtry i sortowanie"
},
"backend::lang.list.results": {
"one": ":count wynik",
"few": ":count wyniki",

View File

@@ -5,7 +5,7 @@ import FormView from '../../src/views/FormView.vue'
import { recordCrumb } from '../../src/state/useBreadcrumbs'
import { toasts } from '../../src/state/useToasts'
import { WIDGET_EVENT } from '../../src/components/form/formContext'
import { readListState, writeListState } from '../../src/state/listState'
import { readListState, setListStateOwner, writeListState } from '../../src/state/listState'
import {
clone,
extensionFormSchemaFixture,
@@ -17,7 +17,7 @@ import {
rosterFormSchemaFixture,
rosterRecordFixture,
} from '../fixtures/typed'
import { API, mountApp, queryOf, requestsTo, resetState, type Reply, type Route } from '../helpers'
import { API, mountApp, profile, queryOf, requestsTo, resetState, type Reply, type Route } from '../helpers'
const BASE = `${API}/acme/demo/widgets`
const RECORD = `${BASE}/1`
@@ -697,6 +697,8 @@ describe('returning to the list keeps its query (D-01)', () => {
}
beforeEach(() => {
// Stored for the admin mountApp signs in (list state is per admin).
setListStateOwner(profile.data.id)
writeListState(LIST_ID, STORED)
})

View File

@@ -3,7 +3,7 @@ import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
import { setBundle } from '../../src/app/i18n'
import ListView from '../../src/views/ListView.vue'
import { toasts } from '../../src/state/useToasts'
import { readListState, writeListState } from '../../src/state/listState'
import { LIST_STATE_PREFIX, readListState, setListStateOwner, writeListState } from '../../src/state/listState'
import { OWNER_ATTRIBUTE } from '../../src/app/pluginAssets'
import {
clone,
@@ -15,7 +15,7 @@ import {
rosterListFixture,
rosterListSchemaFixture,
} from '../fixtures/typed'
import { API, mountApp, queryOf, requestsTo, resetState, wait, type Reply, type Route } from '../helpers'
import { API, mountApp, profile, queryOf, requestsTo, resetState, wait, type Reply, type Route } from '../helpers'
const BASE = `${API}/acme/demo/widgets`
const serverError = { status: 500, body: { error: { code: 'server_error', message: 'Database down', details: {} } } }
@@ -495,6 +495,11 @@ describe('bulk actions on the list (UI-SPEC S1, D-09)', () => {
})
describe('list state in the URL and browser storage (D-01)', () => {
beforeEach(() => {
// Stored for the admin mountApp signs in (list state is per admin).
setListStateOwner(profile.data.id)
})
it('restores the stored query before the first list request when the URL has no list params', async () => {
writeListState('acme.demo.widgets', { sort: 'code', dir: 'desc', filter: { active: 'true' } })
const { calls, router } = await mountApp('/acme/demo/widgets', routes())
@@ -518,7 +523,11 @@ describe('list state in the URL and browser storage (D-01)', () => {
describe('list state entry logic (D-01)', () => {
const ID = 'acme.demo.widgets'
const KEY = `summer-admin.list:${ID}`
const KEY = `summer-admin.list:${profile.data.id}:${ID}`
beforeEach(() => {
setListStateOwner(profile.data.id)
})
function listQueries(calls: Request[]): URLSearchParams[] {
return requestsTo(calls, 'GET', BASE).map(queryOf)
@@ -638,3 +647,172 @@ describe('list state entry logic (D-01)', () => {
expect(readListState(ID)).toEqual({ page: 2 })
})
})
describe('a restored query that fails falls back to the plain list once (WR-02)', () => {
const ID = 'acme.demo.widgets'
const KEY = `summer-admin.list:${profile.data.id}:${ID}`
const invalid = {
status: 422,
body: { error: { code: 'validation_failed', message: 'Invalid', details: { sort: ['unknown column'] } } },
}
function listQueries(calls: Request[]): URLSearchParams[] {
return requestsTo(calls, 'GET', BASE).map(queryOf)
}
/** Fails every list request that carries a sort, answers the plain list. */
function failSorted(reply: Reply | 'throw'): Route {
return (request: Request): Reply => {
if (queryOf(request).get('sort') === null) {
return { body: listFixture }
}
if (reply === 'throw') {
throw new TypeError('Failed to fetch')
}
return reply
}
}
beforeEach(() => {
setListStateOwner(profile.data.id)
})
it.each([
['answers 500', serverError as Reply | 'throw'],
['answers 422', invalid as Reply | 'throw'],
['cannot reach the server', 'throw' as const],
])('forgets the stored query and shows the plain list when the restored request %s', async (_label, reply) => {
writeListState(ID, { sort: 'broken', dir: 'desc', filter: { active: 'true' } })
const { wrapper, calls, router } = await mountApp('/acme/demo/widgets', routes({ [`GET ${BASE}`]: failSorted(reply) }))
await flushPromises()
const sent = listQueries(calls)
expect(sent).toHaveLength(2)
expect(sent[0]!.get('sort')).toBe('broken')
expect(sent[0]!.get('filter[active]')).toBe('true')
expect([...sent[1]!.keys()]).toEqual([])
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets')
expect(window.localStorage.getItem(KEY)).toBeNull()
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
expect(wrapper.find('table').exists()).toBe(true)
expect(wrapper.findAll('[data-sort]').length).toBeGreaterThan(0)
expect(wrapper.findAll('tbody tr')).toHaveLength(listFixture.data.length)
})
it('falls back exactly once: a failing plain list shows the error and asks no more', async () => {
writeListState(ID, { sort: 'broken', dir: 'asc' })
const { wrapper, calls, router } = await mountApp('/acme/demo/widgets', routes({ [`GET ${BASE}`]: serverError }))
await flushPromises()
await wait(20)
await flushPromises()
const sent = listQueries(calls)
expect(sent).toHaveLength(2)
expect(sent[0]!.get('sort')).toBe('broken')
expect([...sent[1]!.keys()]).toEqual([])
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets')
expect(wrapper.find('[role="alert"]').text()).toBe('Nie udało się wczytać listy.')
// Nothing to reset: the plain list itself failed.
expect(wrapper.find('[data-action="reset-list"]').exists()).toBe(false)
expect(window.localStorage.getItem(KEY)).toBeNull()
})
it('restores again on the next entry once a working query is stored', async () => {
writeListState(ID, { sort: 'broken', dir: 'asc' })
const first = await mountApp('/acme/demo/widgets', routes({ [`GET ${BASE}`]: failSorted(serverError) }))
await flushPromises()
expect(listQueries(first.calls)).toHaveLength(2)
first.wrapper.unmount()
writeListState(ID, { search: 'blue' })
const second = await mountApp('/acme/demo/widgets', routes())
await flushPromises()
const sent = listQueries(second.calls)
expect(sent).toHaveLength(1)
expect(sent[0]!.get('search')).toBe('blue')
})
it('keeps a failing URL query on screen with the error and a reset, and stores it as before', async () => {
const { wrapper, calls, router } = await mountApp(
'/acme/demo/widgets?sort=broken&dir=desc',
routes({ [`GET ${BASE}`]: failSorted(serverError) }),
)
await flushPromises()
expect(listQueries(calls)).toHaveLength(1)
expect(router.currentRoute.value.query).toEqual({ sort: 'broken', dir: 'desc' })
expect(wrapper.find('[role="alert"]').text()).toBe('Nie udało się wczytać listy.')
expect(wrapper.find('table').exists()).toBe(false)
expect(readListState(ID)).toEqual({ sort: 'broken', dir: 'desc' })
const reset = wrapper.find('[data-action="reset-list"]')
expect(reset.text()).toBe('Wyczyść wyszukiwanie, filtry i sortowanie')
await reset.trigger('click')
await flushPromises()
const sent = listQueries(calls)
expect(sent).toHaveLength(2)
expect([...sent[1]!.keys()]).toEqual([])
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets')
expect(window.localStorage.getItem(KEY)).toBeNull()
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
expect(wrapper.find('table').exists()).toBe(true)
})
it('keeps a URL query that answers 422 on screen, drops it from storage and offers the reset', async () => {
writeListState(ID, { search: 'older' })
const { wrapper, calls, router } = await mountApp(
'/acme/demo/widgets?sort=broken&dir=asc',
routes({ [`GET ${BASE}`]: failSorted(invalid) }),
)
await flushPromises()
expect(listQueries(calls)).toHaveLength(1)
expect(router.currentRoute.value.query).toEqual({ sort: 'broken', dir: 'asc' })
expect(window.localStorage.getItem(KEY)).toBeNull()
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
expect(wrapper.find('[data-action="reset-list"]').exists()).toBe(true)
})
it('shows no reset when the plain list fails from the URL', async () => {
const { wrapper, calls } = await mountApp('/acme/demo/widgets', routes({ [`GET ${BASE}`]: serverError }))
await flushPromises()
expect(listQueries(calls)).toHaveLength(1)
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
expect(wrapper.find('[data-action="reset-list"]').exists()).toBe(false)
})
})
describe('stored list queries are per admin (WR-03, T-16.1-03)', () => {
const ID = 'acme.demo.widgets'
const other = { ...profile, data: { ...profile.data, id: 8, login: 'other' } }
it("never restores another admin's query and removes it when the next admin signs in", async () => {
setListStateOwner(profile.data.id)
writeListState(ID, { search: 'jan.kowalski@example.test' })
expect(window.localStorage.getItem(`${LIST_STATE_PREFIX}${profile.data.id}:${ID}`)).not.toBeNull()
const { calls, router } = await mountApp('/acme/demo/widgets', routes({ [`GET ${API}/auth/me`]: { body: other } }))
await flushPromises()
const sent = requestsTo(calls, 'GET', BASE).map(queryOf)
expect(sent).toHaveLength(1)
expect([...sent[0]!.keys()]).toEqual([])
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets')
expect(Object.keys(window.localStorage).filter((key) => key.startsWith(LIST_STATE_PREFIX))).toEqual([])
})
it('never restores an unscoped key of an older build and sweeps it', async () => {
window.localStorage.setItem(`${LIST_STATE_PREFIX}${ID}`, JSON.stringify({ search: 'legacy' }))
window.localStorage.setItem(`${LIST_STATE_PREFIX}acme.demo.gadgets`, JSON.stringify({ sort: 'name' }))
const { calls } = await mountApp('/acme/demo/widgets', routes())
await flushPromises()
const sent = requestsTo(calls, 'GET', BASE).map(queryOf)
expect(sent).toHaveLength(1)
expect(sent[0]!.get('search')).toBeNull()
expect(Object.keys(window.localStorage).filter((key) => key.startsWith(LIST_STATE_PREFIX))).toEqual([])
})
it('stores the next query under the signed-in admin only', async () => {
const { calls } = await mountApp('/acme/demo/widgets?search=blue', routes({ [`GET ${API}/auth/me`]: { body: other } }))
await flushPromises()
expect(requestsTo(calls, 'GET', BASE)).toHaveLength(1)
expect(Object.keys(window.localStorage).filter((key) => key.startsWith(LIST_STATE_PREFIX))).toEqual([
`${LIST_STATE_PREFIX}8:${ID}`,
])
})
})

View File

@@ -1,28 +1,41 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { clearListStates, LIST_STATE_PREFIX, readListState, writeListState } from '../../src/state/listState'
import {
clearListStates,
LIST_STATE_PREFIX,
listStateOwner,
listStateOwnerPrefix,
readListState,
setListStateOwner,
writeListState,
} from '../../src/state/listState'
import { SIDEBAR_KEY } from '../../src/state/useSidebar'
// Browser-storage helpers of the per-list query memory (D-01).
// Browser-storage helpers of the per-list query memory (D-01), scoped to the
// signed-in admin (WR-03, T-16.1-03).
const ID = 'acme.demo.widgets'
const KEY = `${LIST_STATE_PREFIX}${ID}`
const OWNER = 7
const KEY = `${LIST_STATE_PREFIX}${OWNER}:${ID}`
beforeEach(() => {
window.localStorage.clear()
setListStateOwner(OWNER)
})
afterEach(() => {
vi.restoreAllMocks()
setListStateOwner(null)
window.localStorage.clear()
})
describe('list state keys', () => {
it('uses the summer-admin.list: prefix plus the controller id, one key per list', () => {
it('uses the summer-admin.list: prefix plus the admin id and the controller id, one key per list', () => {
expect(LIST_STATE_PREFIX).toBe('summer-admin.list:')
expect(listStateOwnerPrefix(OWNER)).toBe('summer-admin.list:7:')
writeListState(ID, { search: 'blue' })
writeListState('acme.demo.gadgets', { sort: 'name', dir: 'asc' })
expect(Object.keys(window.localStorage).sort()).toEqual([
'summer-admin.list:acme.demo.gadgets',
'summer-admin.list:acme.demo.widgets',
'summer-admin.list:7:acme.demo.gadgets',
'summer-admin.list:7:acme.demo.widgets',
])
expect(JSON.parse(window.localStorage.getItem(KEY)!)).toEqual({ search: 'blue' })
expect(readListState('acme.demo.gadgets')).toEqual({ sort: 'name', dir: 'asc' })
@@ -120,6 +133,69 @@ describe('writeListState', () => {
})
})
describe('list state owner', () => {
const OTHER_KEY = `${LIST_STATE_PREFIX}8:${ID}`
const LEGACY_KEY = `${LIST_STATE_PREFIX}${ID}`
it('keeps the owner as a string and null when signed out', () => {
expect(listStateOwner()).toBe('7')
setListStateOwner(null)
expect(listStateOwner()).toBeNull()
setListStateOwner('')
expect(listStateOwner()).toBeNull()
})
it("never reads another admin's query and removes it when the owner changes", () => {
writeListState(ID, { search: 'jan.kowalski@example.test' })
setListStateOwner(8)
expect(window.localStorage.getItem(KEY)).toBeNull()
expect(readListState(ID)).toEqual({})
writeListState(ID, { sort: 'name', dir: 'asc' })
expect(Object.keys(window.localStorage)).toEqual([OTHER_KEY])
})
it("does not read another admin's key even before any sweep", () => {
window.localStorage.setItem(OTHER_KEY, JSON.stringify({ search: 'theirs' }))
expect(readListState(ID)).toEqual({})
expect(window.localStorage.getItem(OTHER_KEY)).not.toBeNull()
})
it("keeps the owner's own keys when the same owner is set again", () => {
writeListState(ID, { search: 'blue' })
setListStateOwner('7')
expect(readListState(ID)).toEqual({ search: 'blue' })
})
it('sweeps the unscoped keys of older builds and never reads them', () => {
window.localStorage.setItem(LEGACY_KEY, JSON.stringify({ search: 'legacy' }))
window.localStorage.setItem(`${LIST_STATE_PREFIX}acme.demo.gadgets`, JSON.stringify({ sort: 'name' }))
window.localStorage.setItem(SIDEBAR_KEY, 'true')
expect(readListState(ID)).toEqual({})
setListStateOwner(OWNER)
expect(Object.keys(window.localStorage)).toEqual([SIDEBAR_KEY])
})
it('does not take an admin id that only starts with the owner id for the owner', () => {
window.localStorage.setItem(`${LIST_STATE_PREFIX}70:${ID}`, JSON.stringify({ search: 'seventy' }))
writeListState(ID, { search: 'seven' })
setListStateOwner(OWNER)
expect(Object.keys(window.localStorage)).toEqual([KEY])
})
it('removes every list key and reads and writes nothing once signed out', () => {
window.localStorage.setItem(SIDEBAR_KEY, 'true')
window.localStorage.setItem(OTHER_KEY, JSON.stringify({ search: 'theirs' }))
window.localStorage.setItem(LEGACY_KEY, JSON.stringify({ search: 'legacy' }))
writeListState(ID, { search: 'blue' })
setListStateOwner(null)
expect(Object.keys(window.localStorage)).toEqual([SIDEBAR_KEY])
writeListState(ID, { search: 'blue' })
expect(Object.keys(window.localStorage)).toEqual([SIDEBAR_KEY])
window.localStorage.setItem(KEY, JSON.stringify({ search: 'planted' }))
expect(readListState(ID)).toEqual({})
})
})
describe('clearListStates', () => {
it('removes every list key and keeps the sidebar preference and other keys', () => {
window.localStorage.setItem(SIDEBAR_KEY, 'true')
@@ -127,6 +203,8 @@ describe('clearListStates', () => {
writeListState(ID, { search: 'blue' })
writeListState('acme.demo.gadgets', { sort: 'name', dir: 'desc' })
writeListState('acme.other.people', { filter: { active: 'true' } })
window.localStorage.setItem(`${LIST_STATE_PREFIX}8:${ID}`, JSON.stringify({ search: 'theirs' }))
window.localStorage.setItem(`${LIST_STATE_PREFIX}${ID}`, JSON.stringify({ search: 'legacy' }))
clearListStates()
expect(Object.keys(window.localStorage).sort()).toEqual(['summer-admin.sidebar', 'unrelated'])
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('true')
@@ -156,6 +234,8 @@ describe('storage that throws', () => {
expect(() => writeListState(ID, { search: 'blue' })).not.toThrow()
expect(() => writeListState(ID, {})).not.toThrow()
expect(() => clearListStates()).not.toThrow()
expect(() => setListStateOwner(8)).not.toThrow()
expect(() => setListStateOwner(null)).not.toThrow()
})
it('drops a malformed value even when the removal throws', () => {
@@ -174,5 +254,6 @@ describe('storage that throws', () => {
expect(() => writeListState(ID, { search: 'blue' })).not.toThrow()
expect(() => writeListState(ID, {})).not.toThrow()
expect(() => clearListStates()).not.toThrow()
expect(() => setListStateOwner(8)).not.toThrow()
})
})

View File

@@ -1,7 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMemoryHistory } from 'vue-router'
import { api, onUnauthorized } from '../../src/api/client'
import { createAdminRouter } from '../../src/app/router'
import {
clearUser,
currentUser,
expireSession,
login,
logout,
me,
@@ -9,7 +13,7 @@ import {
setCurrentUser,
useAuth,
} from '../../src/state/useAuth'
import { writeListState } from '../../src/state/listState'
import { LIST_STATE_PREFIX, listStateOwner, readListState, writeListState } from '../../src/state/listState'
import { navigation, setNavigation } from '../../src/state/useNavigation'
import { SIDEBAR_KEY } from '../../src/state/useSidebar'
import { setSettings, settings } from '../../src/state/useSettings'
@@ -166,6 +170,129 @@ describe('logout (T-10-23)', () => {
expect(auth.logout).toBe(logout)
expect(auth.me).toBe(me)
expect(auth.clearUser).toBe(clearUser)
expect(auth.expireSession).toBe(expireSession)
})
})
describe('stored list queries follow the session (WR-03, T-16.1-03)', () => {
const ID = 'acme.demo.widgets'
const other = { ...profile, data: { ...profile.data, id: 8, login: 'other' } }
const listKeys = () => Object.keys(window.localStorage).filter((key) => key.startsWith(LIST_STATE_PREFIX))
async function signInAs(envelope: typeof profile, routes: Parameters<typeof mockApi>[0] = {}) {
const calls = mockApi({ [`GET ${API}/auth/me`]: { body: envelope }, ...routes })
await me()
return calls
}
afterEach(() => {
onUnauthorized(null)
})
it('scopes the stored queries to the admin me() loads', async () => {
await signInAs(profile)
expect(listStateOwner()).toBe('7')
writeListState(ID, { search: 'blue' })
expect(listKeys()).toEqual([`${LIST_STATE_PREFIX}7:${ID}`])
expect(readListState(ID)).toEqual({ search: 'blue' })
})
it("never hands one admin's query to the next admin who signs in on this browser", async () => {
await signInAs(profile)
writeListState(ID, { search: 'jan.kowalski@example.test' })
writeListState('acme.demo.gadgets', { sort: 'name', dir: 'asc' })
// The tab was closed without a logout; another admin signs in.
await signInAs(other)
expect(listStateOwner()).toBe('8')
expect(readListState(ID)).toEqual({})
expect(listKeys()).toEqual([])
})
it('keeps the own queries when the same admin loads again or edits the profile', async () => {
await signInAs(profile)
writeListState(ID, { search: 'blue' })
await signInAs(profile)
setCurrentUser({ ...profile.data, first_name: 'Grace' })
expect(readListState(ID)).toEqual({ search: 'blue' })
})
it('removes every stored query when me() finds no session', async () => {
await signInAs(profile)
writeListState(ID, { search: 'blue' })
window.localStorage.setItem(`${LIST_STATE_PREFIX}${ID}`, JSON.stringify({ search: 'legacy' }))
mockApi({ [`GET ${API}/auth/me`]: unauthorized })
expect(await me()).toBeNull()
expect(listStateOwner()).toBeNull()
expect(listKeys()).toEqual([])
})
it('sweeps unscoped keys of older builds when the profile loads', async () => {
window.localStorage.setItem(`${LIST_STATE_PREFIX}${ID}`, JSON.stringify({ search: 'legacy' }))
window.localStorage.setItem(SIDEBAR_KEY, 'true')
await signInAs(profile)
expect(readListState(ID)).toEqual({})
expect(listKeys()).toEqual([])
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('true')
})
it('clearUser removes every stored query and stops storing', async () => {
await signInAs(profile)
writeListState(ID, { search: 'blue' })
clearUser()
expect(listKeys()).toEqual([])
writeListState(ID, { search: 'blue' })
expect(listKeys()).toEqual([])
})
it('expireSession clears the session and the stored queries and routes to login with the redirect', async () => {
await signInAs(profile)
writeListState(ID, { search: 'blue' })
const push = vi.fn().mockResolvedValue(undefined)
expireSession({ currentRoute: { value: { name: 'list', fullPath: '/acme/demo/widgets?search=blue' } }, push })
expect(currentUser.value).toBeNull()
expect(listKeys()).toEqual([])
expect(push).toHaveBeenCalledWith({ name: 'login', query: { redirect: '/acme/demo/widgets?search=blue' } })
})
it('expireSession stays on the login screen and swallows a failed navigation', async () => {
await signInAs(profile)
writeListState(ID, { search: 'blue' })
const push = vi.fn().mockResolvedValue(undefined)
expireSession({ currentRoute: { value: { name: 'login', fullPath: '/login' } }, push })
expect(push).not.toHaveBeenCalled()
expect(listKeys()).toEqual([])
await signInAs(profile)
const failing = vi.fn().mockRejectedValue(new Error('aborted'))
expireSession({ currentRoute: { value: { name: 'list', fullPath: '/acme/demo/widgets' } }, push: failing })
await Promise.resolve()
expect(failing).toHaveBeenCalledTimes(1)
})
it('a 401 the refresh cannot recover clears the stored queries through the onUnauthorized handler of main.ts', async () => {
const calls = await signInAs(profile, {
[`GET ${API}/acme/demo/widgets`]: unauthorized,
[`POST ${API}/auth/refresh`]: unauthorized,
})
const router = createAdminRouter(createMemoryHistory())
await router.push('/acme/demo/widgets?search=blue')
await router.isReady()
// The same wiring as main.ts.
onUnauthorized(() => expireSession(router))
writeListState(ID, { search: 'jan.kowalski@example.test' })
expect(listKeys()).toHaveLength(1)
await api.GET('/{vendor}/{plugin}/{controller}', {
params: { path: { vendor: 'acme', plugin: 'demo', controller: 'widgets' }, query: {} },
})
await new Promise((resolve) => setTimeout(resolve, 0))
expect(requestsTo(calls, 'POST', `${API}/auth/refresh`)).toHaveLength(1)
expect(currentUser.value).toBeNull()
expect(listKeys()).toEqual([])
expect(router.currentRoute.value.name).toBe('login')
expect(router.currentRoute.value.query.redirect).toBe('/acme/demo/widgets?search=blue')
})
})