Files
windmill/frontend/src/lib/userScopedStorage.test.ts
T
Guilhem aa26c4d9b2 feat: scope AI session storage per user, session list in IndexedDB (#9518)
* feat: scope AI session browser storage to the logged-in user

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: address review nits in user-scoped session storage

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: store AI session list in per-user IndexedDB via shared userScopedDb

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: cover legacy chat-history migration; address review nits

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: harden chat-DB writes and session-key cleanup per PR review

- route HistoryManager writes through whenReady() so they can't land in a
  previous user's DB after an in-place user switch (drop cached this.indexDB)
- dedup the legacy chat-DB claim to one session-wide promise so racing
  manager instances can't issue blocking concurrent deleteDB calls
- delete bare windmill_sessions keys unconditionally even when the user DB is
  already populated, closing a partial-migration leak window

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: trim package-lock to only the fake-indexeddb addition

npm install -D had also stripped "dev": true from ~34 unrelated optional
native-binding packages (npm graph recomputation). Restore main's lockfile and
graft in only the fake-indexeddb node, so the lock diff is exactly the intended
dev dependency and prod-install classification of those bindings is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:31:12 +02:00

82 lines
2.9 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest'
// The userStore subscription is gated on BROWSER; the vitest "server" env
// reports BROWSER=false.
vi.mock('esm-env', async (importOriginal) => ({
...(await importOriginal<typeof import('esm-env')>()),
BROWSER: true
}))
import { userStore, type UserExt } from '$lib/stores'
import {
scopedKey,
getCurrentUserEmail,
onUserChange,
migrateLegacyLocalStorage
} from './userScopedStorage'
function asUser(email: string): UserExt {
return { email, username: email.split('@')[0] } as unknown as UserExt
}
beforeEach(() => {
localStorage.clear()
userStore.set(undefined)
})
describe('scopedKey / getCurrentUserEmail', () => {
it('returns undefined while no user is logged in', () => {
expect(getCurrentUserEmail()).toBeUndefined()
expect(scopedKey('windmill_sessions')).toBeUndefined()
})
it('namespaces by email and isolates distinct users', () => {
userStore.set(asUser('a@x.com'))
expect(scopedKey('windmill_sessions')).toBe('windmill_sessions::a@x.com')
userStore.set(asUser('b@y.com'))
expect(scopedKey('windmill_sessions')).toBe('windmill_sessions::b@y.com')
})
})
describe('onUserChange', () => {
it('fires immediately with the current email, then on every change', () => {
userStore.set(asUser('a@x.com'))
const calls: Array<[string | undefined, string | undefined]> = []
onUserChange((email, prev) => calls.push([email, prev]))
// Immediate fire with current email, prev undefined.
expect(calls).toEqual([['a@x.com', undefined]])
userStore.set(asUser('b@y.com'))
expect(calls).toEqual([
['a@x.com', undefined],
['b@y.com', 'a@x.com']
])
// No-op when the email is unchanged.
userStore.set(asUser('b@y.com'))
expect(calls).toHaveLength(2)
})
})
describe('migrateLegacyLocalStorage', () => {
it('claims a legacy key into the target and deletes the legacy copy', () => {
localStorage.setItem('ai-chat-autonomy-mode', 'yolo')
migrateLegacyLocalStorage('ai-chat-autonomy-mode', 'ai-chat-autonomy-mode::a@x.com')
expect(localStorage.getItem('ai-chat-autonomy-mode::a@x.com')).toBe('yolo')
expect(localStorage.getItem('ai-chat-autonomy-mode')).toBeNull()
})
it('does not overwrite an existing target', () => {
localStorage.setItem('ai-chat-autonomy-mode', 'yolo')
localStorage.setItem('ai-chat-autonomy-mode::a@x.com', 'acceptedit')
migrateLegacyLocalStorage('ai-chat-autonomy-mode', 'ai-chat-autonomy-mode::a@x.com')
expect(localStorage.getItem('ai-chat-autonomy-mode::a@x.com')).toBe('acceptedit')
// Legacy left untouched since the target was already populated.
expect(localStorage.getItem('ai-chat-autonomy-mode')).toBe('yolo')
})
it('is a no-op when the target key is undefined (no user)', () => {
localStorage.setItem('ai-chat-autonomy-mode', 'yolo')
migrateLegacyLocalStorage('ai-chat-autonomy-mode', undefined)
expect(localStorage.getItem('ai-chat-autonomy-mode')).toBe('yolo')
})
})