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() }) })