test(admin): list state round trip and storage coverage
- Form back, cancel, save-and-close and delete land on the list with the stored query; every list request after the return carries it - listState helpers: key prefix, malformed stored values removed, empty query stores nothing, throwing storage - ListView entry: URL wins, cleared controls forget the query, same-list navigation, 422 forgets, page clamp - Logout removes every list key and keeps the sidebar preference
This commit is contained in:
178
admin/tests/state/listState.test.ts
Normal file
178
admin/tests/state/listState.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { clearListStates, LIST_STATE_PREFIX, readListState, writeListState } from '../../src/state/listState'
|
||||
import { SIDEBAR_KEY } from '../../src/state/useSidebar'
|
||||
|
||||
// Browser-storage helpers of the per-list query memory (D-01).
|
||||
const ID = 'acme.demo.widgets'
|
||||
const KEY = `${LIST_STATE_PREFIX}${ID}`
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
describe('list state keys', () => {
|
||||
it('uses the summer-admin.list: prefix plus the controller id, one key per list', () => {
|
||||
expect(LIST_STATE_PREFIX).toBe('summer-admin.list:')
|
||||
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',
|
||||
])
|
||||
expect(JSON.parse(window.localStorage.getItem(KEY)!)).toEqual({ search: 'blue' })
|
||||
expect(readListState('acme.demo.gadgets')).toEqual({ sort: 'name', dir: 'asc' })
|
||||
})
|
||||
|
||||
it('reads a missing key as the empty query', () => {
|
||||
expect(readListState(ID)).toEqual({})
|
||||
expect(window.localStorage.getItem(KEY)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('readListState', () => {
|
||||
it.each([
|
||||
['invalid JSON', '{"search": '],
|
||||
['a JSON string', '"search=blue"'],
|
||||
['a JSON number', '42'],
|
||||
['JSON null', 'null'],
|
||||
['an array', '["search", "blue"]'],
|
||||
['an object with a number value', '{"search": "blue", "page": 2}'],
|
||||
['an object with a nested object', '{"filter": {"active": "true"}}'],
|
||||
['an object with a boolean value', '{"filter[active]": true}'],
|
||||
])('returns the empty query for %s and removes the key', (_label, raw) => {
|
||||
window.localStorage.setItem(KEY, raw)
|
||||
expect(readListState(ID)).toEqual({})
|
||||
expect(window.localStorage.getItem(KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('drops malformed values through the strict parser like a hand-edited URL', () => {
|
||||
window.localStorage.setItem(
|
||||
KEY,
|
||||
JSON.stringify({
|
||||
search: ' ',
|
||||
dir: 'sideways',
|
||||
page: '0',
|
||||
per_page: 'many',
|
||||
'filter[active]': '',
|
||||
'filter[maker]': '3',
|
||||
unknown: 'value',
|
||||
'filter[': 'x',
|
||||
}),
|
||||
)
|
||||
expect(readListState(ID)).toEqual({ filter: { maker: '3' } })
|
||||
})
|
||||
|
||||
it('reads an unknown direction next to a sort as ascending', () => {
|
||||
window.localStorage.setItem(KEY, JSON.stringify({ sort: 'code', dir: 'sideways' }))
|
||||
expect(readListState(ID)).toEqual({ sort: 'code', dir: 'asc' })
|
||||
})
|
||||
|
||||
it('keeps a well-formed stored object of strings in place', () => {
|
||||
window.localStorage.setItem(KEY, JSON.stringify({ search: 'blue', page: '3' }))
|
||||
expect(readListState(ID)).toEqual({ search: 'blue', page: 3 })
|
||||
expect(window.localStorage.getItem(KEY)).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('writeListState', () => {
|
||||
it('round-trips search, sort, dir, a page above 1, per_page and filters', () => {
|
||||
const query = {
|
||||
search: 'blue',
|
||||
sort: 'code',
|
||||
dir: 'desc' as const,
|
||||
page: 3,
|
||||
per_page: 50,
|
||||
filter: { active: 'true', maker: '7' },
|
||||
}
|
||||
writeListState(ID, query)
|
||||
expect(JSON.parse(window.localStorage.getItem(KEY)!)).toEqual({
|
||||
search: 'blue',
|
||||
sort: 'code',
|
||||
dir: 'desc',
|
||||
page: '3',
|
||||
per_page: '50',
|
||||
'filter[active]': 'true',
|
||||
'filter[maker]': '7',
|
||||
})
|
||||
expect(readListState(ID)).toEqual(query)
|
||||
})
|
||||
|
||||
it('stores the first page as no page', () => {
|
||||
writeListState(ID, { sort: 'name', dir: 'asc', page: 1 })
|
||||
expect(readListState(ID)).toEqual({ sort: 'name', dir: 'asc' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['the empty query', {}],
|
||||
['a query of empty filter values', { filter: { active: '' } }],
|
||||
['a first page only', { page: 1 }],
|
||||
])('removes the key for %s instead of storing it', (_label, query) => {
|
||||
writeListState(ID, { search: 'blue' })
|
||||
expect(window.localStorage.getItem(KEY)).not.toBeNull()
|
||||
writeListState(ID, query)
|
||||
expect(window.localStorage.getItem(KEY)).toBeNull()
|
||||
expect(Object.keys(window.localStorage)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearListStates', () => {
|
||||
it('removes every list key and keeps the sidebar preference and other keys', () => {
|
||||
window.localStorage.setItem(SIDEBAR_KEY, 'true')
|
||||
window.localStorage.setItem('unrelated', 'kept')
|
||||
writeListState(ID, { search: 'blue' })
|
||||
writeListState('acme.demo.gadgets', { sort: 'name', dir: 'desc' })
|
||||
writeListState('acme.other.people', { filter: { active: 'true' } })
|
||||
clearListStates()
|
||||
expect(Object.keys(window.localStorage).sort()).toEqual(['summer-admin.sidebar', 'unrelated'])
|
||||
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('true')
|
||||
})
|
||||
|
||||
it('is a no-op on empty storage', () => {
|
||||
expect(() => clearListStates()).not.toThrow()
|
||||
expect(Object.keys(window.localStorage)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('storage that throws', () => {
|
||||
it('reads the empty query and writes and clears without throwing', () => {
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new DOMException('denied', 'SecurityError')
|
||||
})
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new DOMException('full', 'QuotaExceededError')
|
||||
})
|
||||
vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => {
|
||||
throw new DOMException('denied', 'SecurityError')
|
||||
})
|
||||
vi.spyOn(Storage.prototype, 'key').mockImplementation(() => {
|
||||
throw new DOMException('denied', 'SecurityError')
|
||||
})
|
||||
expect(readListState(ID)).toEqual({})
|
||||
expect(() => writeListState(ID, { search: 'blue' })).not.toThrow()
|
||||
expect(() => writeListState(ID, {})).not.toThrow()
|
||||
expect(() => clearListStates()).not.toThrow()
|
||||
})
|
||||
|
||||
it('drops a malformed value even when the removal throws', () => {
|
||||
window.localStorage.setItem(KEY, '{broken')
|
||||
vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => {
|
||||
throw new DOMException('denied', 'SecurityError')
|
||||
})
|
||||
expect(readListState(ID)).toEqual({})
|
||||
})
|
||||
|
||||
it('treats a window whose localStorage getter throws as no storage', () => {
|
||||
vi.spyOn(window, 'localStorage', 'get').mockImplementation(() => {
|
||||
throw new DOMException('denied', 'SecurityError')
|
||||
})
|
||||
expect(readListState(ID)).toEqual({})
|
||||
expect(() => writeListState(ID, { search: 'blue' })).not.toThrow()
|
||||
expect(() => writeListState(ID, {})).not.toThrow()
|
||||
expect(() => clearListStates()).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
setCurrentUser,
|
||||
useAuth,
|
||||
} from '../../src/state/useAuth'
|
||||
import { 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'
|
||||
import { navigationFixture, settingsFixture } from '../fixtures/typed'
|
||||
import { API, mockApi, profile, requestsTo, resetState } from '../helpers'
|
||||
@@ -129,6 +131,24 @@ describe('logout (T-10-23)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['succeeds', { body: { data: { status: 'logged_out' }, meta: {} } }],
|
||||
['answers 500', { status: 500, body: { error: { code: 'server', message: 'x', details: {} } } }],
|
||||
['fails on the network', 'throw' as const],
|
||||
])('removes every stored list query and keeps the sidebar preference when the call %s', async (_label, route) => {
|
||||
await signedIn(route)
|
||||
window.localStorage.setItem(SIDEBAR_KEY, 'true')
|
||||
writeListState('acme.demo.widgets', { search: 'blue', sort: 'code', dir: 'desc' })
|
||||
writeListState('acme.demo.gadgets', { filter: { active: 'true' } })
|
||||
expect(Object.keys(window.localStorage).filter((key) => key.startsWith('summer-admin.list:'))).toHaveLength(2)
|
||||
|
||||
await logout({ replace: vi.fn().mockResolvedValue(undefined) })
|
||||
|
||||
expect(Object.keys(window.localStorage).filter((key) => key.startsWith('summer-admin.list:'))).toEqual([])
|
||||
expect(Object.keys(window.localStorage)).toEqual([SIDEBAR_KEY])
|
||||
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('true')
|
||||
})
|
||||
|
||||
it('works without a router and swallows a failed navigation', async () => {
|
||||
await signedIn({ body: { data: { status: 'logged_out' }, meta: {} } })
|
||||
await expect(logout()).resolves.toBeUndefined()
|
||||
|
||||
Reference in New Issue
Block a user