Files
summercms/admin/tests/state/listState.test.ts
Jakub Zych cd25af0878 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
2026-10-09 23:53:19 +02:00

260 lines
9.7 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
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), scoped to the
// signed-in admin (WR-03, T-16.1-03).
const ID = 'acme.demo.widgets'
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 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: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' })
})
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('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')
window.localStorage.setItem('unrelated', 'kept')
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')
})
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()
expect(() => setListStateOwner(8)).not.toThrow()
expect(() => setListStateOwner(null)).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()
expect(() => setListStateOwner(8)).not.toThrow()
})
})