import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { api } from '../src/api' import { auth } from '../src/auth' import { store } from '../src/store' import type { ActivityCard, Conversation, Project } from '../src/contracts' import { assignDocDutiesPayload, memberAddPayload } from '../src/rdmManagement' const project = (id: string, settings?: Project['settings']): Project => ({ id, name: id, settings, createdAt: '', updatedAt: '', }) const ready = project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' }) const task = (id: string): Conversation => ({ id, projectId: 'host-1', title: id, createdAt: '', updatedAt: '' }) function deferred() { let resolve!: (value: T) => void const promise = new Promise(ok => { resolve = ok }) return { promise, resolve } } const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status }) let projects: Project[] let tasks: Conversation[] let requests: { path: string; init?: RequestInit }[] let route: (path: string, init?: RequestInit) => Promise beforeEach(async () => { projects = []; tasks = []; requests = [] const saved = new Map() vi.stubGlobal('localStorage', { getItem: (key: string) => saved.get(key) ?? null, setItem: (key: string, value: string) => { saved.set(key, value) }, removeItem: (key: string) => { saved.delete(key) } }) route = async (path, init) => { if (path === '/api/projects' && !init?.method) return response(projects) if (path.startsWith('/api/conversations') && !init?.method) return response(tasks) if (path === '/api/me') return response({ owner: 'alice', name: 'Alice', auth: true }) if (path.endsWith('/artifacts')) return response([]) if (path.endsWith('/cards')) return response({ cards: [] }) if (path.endsWith('/surfaces')) return response([]) if (['/api/profiles', '/api/plugins', '/api/workspaces', '/api/commands', '/api/admin-zone/sections'].includes(path)) return response([]) if (path === '/api/settings') return response({ schema: [], defaults: {}, user: {}, project: {} }) if (path.startsWith('/api/commands/')) return response({ payload: { ok: true } }) return response({}) } vi.stubGlobal('fetch', async (path: string, init?: RequestInit) => { requests.push({ path, init }) return route(path, init) }) vi.stubGlobal('EventSource', class { addEventListener() {} close() {} }) auth.beginSession() await store.refreshProjects() await store.refreshTasks() store.setUI({ lastProjectId: null }) requests = [] }) afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() }) describe('project command identity', () => { it.each(['archived', 'closed'])('preserves canonical member, permission and duty reads for a %s project', async state => { const historical = project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': state }) const commands = ['rdm.project.member.list', 'rdm.perm.check', 'rdm.perm.matrix', 'rdm.review.doc.status', 'rdm.duty.catalog', 'rdm.duty.get'] for (const command of commands) await api.rdmProjectCommand(historical, command, { docId: 'doc-2' }) expect(requests.map(r => ({ path: r.path, params: JSON.parse(String(r.init?.body)).params }))).toEqual([ { path: '/api/commands/rdm.project.member.list', params: { projectId: 'domain-7', docId: 'doc-2' } }, { path: '/api/commands/rdm.perm.check', params: { projectId: 'domain-7', docId: 'doc-2' } }, { path: '/api/commands/rdm.perm.matrix', params: { projectId: 'domain-7', docId: 'doc-2' } }, { path: '/api/commands/rdm.review.doc.status', params: { projectId: 'domain-7', docId: 'doc-2' } }, { path: '/api/commands/rdm.duty.catalog', params: { projectId: 'domain-7', docId: 'doc-2' } }, { path: '/api/commands/rdm.duty.get', params: { projectId: 'domain-7', docId: 'doc-2' } }, ]) }) it.each(['archived', 'closed'])('still blocks project and document mutations for a %s project', async state => { const historical = project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': state }) for (const command of ['rdm.project.member.add', 'rdm.project.member.remove', 'rdm.project.member.setRole', 'rdm.review.assign.doc', 'rdm.review.doc.submit', 'rdm.review.doc.approve', 'rdm.review.doc.reject', 'rdm.review.doc.final']) { await expect(api.rdmProjectCommand(historical, command)).rejects.toThrow() } expect(requests).toEqual([]) }) it.each(['rdm.perm.matrix', 'rdm.duty.catalog', 'rdm.duty.get', 'rdm.project.member.setRole'])('uses the same canonical boundary for %s', async command => { await api.rdmProjectCommand(ready, command, { projectId: 'another-host', member: 'bob' }) expect(requests.map(r => ({ path: r.path, params: JSON.parse(String(r.init?.body)).params }))).toEqual([ { path: `/api/commands/${command}`, params: { projectId: 'domain-7', member: 'bob' } }, ]) }) it('sends member and duty commands to the canonical domain project from service metadata', async () => { await api.rdmProjectCommand(ready, 'rdm.project.member.add', memberAddPayload(ready.id, 'bob')) await api.rdmProjectCommand(ready, 'rdm.review.assign.doc', assignDocDutiesPayload(ready.id, 'doc-2', 'alice', ['bob'], 'carol')) expect(requests.map(r => JSON.parse(String(r.init?.body)).params)).toEqual([ { projectId: 'domain-7', member: 'bob', role: 'member' }, { projectId: 'domain-7', docId: 'doc-2', author: 'alice', reviewers: ['bob'], approver: 'carol' }, ]) }) it.each([ project('host-1', { 'rdm.state': 'unbound' }), project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': 'failed' }), project('host-1', { 'rdm.projectId': 'domain-7' }), project('host-1'), ])('does not invent a domain ID or send commands for an unready binding: %j', async p => { await expect(api.rdmProjectCommand(p, 'rdm.project.member.list')).rejects.toThrow() expect(requests).toEqual([]) }) }) describe('project work and mutation failures', () => { it('keeps private history readable for an archived project while refusing new work', async () => { projects = [project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': 'archived' })] tasks = [task('private-history')] const previous = route route = async (path, init) => { if (path === '/api/conversations/private-history') return response({ conversation: tasks[0], messages: [{ id: 'own-message', conversationId: 'private-history', role: 'user', parts: [{ type: 'text', text: 'My history' }], createdAt: '' }], runs: [], activeHitl: [], lastSeq: 0 }) if (path.endsWith('/plan') || path.endsWith('/todos')) return response(null) return previous(path, init) } await store.refreshProjects() await store.openTask('private-history') await expect(store.createTask()).rejects.toThrow() expect(store.getState().currentConvId).toBe('private-history') expect(store.getState().messages.map(m => m.id)).toEqual(['own-message']) expect(requests.filter(r => r.init?.method === 'POST')).toEqual([]) }) it.each([ready, project('host-1')])('creates private work with the host ID in ready RDM and ordinary base mode: %j', async p => { projects = [p] await store.refreshProjects() const previous = route route = async (path, init) => { if (path === '/api/projects/host-1/conversations' && init?.method === 'POST') { tasks = [task('new-private')] return response(tasks[0]) } if (path === '/api/conversations/new-private') { return response({ conversation: tasks[0], messages: [], runs: [], activeHitl: [], lastSeq: 0 }) } if (path.endsWith('/plan') || path.endsWith('/todos')) return response(null) return previous(path, init) } await store.createTask() expect(store.getState().currentConvId).toBe('new-private') expect(requests.filter(r => r.init?.method === 'POST').map(r => r.path)).toEqual(['/api/projects/host-1/conversations']) }) it.each(['provisioning', 'failed', 'unbound', 'archived', 'closed'])('does not create a fallback project or conversation for a selected %s project', async state => { projects = [project('host-1', { 'rdm.state': state }), project('host-2')] await store.refreshProjects() requests = [] await expect(store.createTask()).rejects.toThrow() expect(requests.filter(r => r.init?.method === 'POST')).toEqual([]) expect(store.getState().currentProjectId).toBe('host-1') expect(store.getState().toast).toMatch(/项目.*(稍后|负责人|恢复|关闭)/) }) it('does not bypass a selected project absent from the current list', async () => { await store.openProject('revoked-project') requests = [] await expect(store.createTask()).rejects.toThrow() expect(requests.filter(r => r.init?.method === 'POST')).toEqual([]) }) it.each(['delete', 'rename'])('rejects a denied %s instead of resolving success', async action => { route = async () => response({ error: 'project administration required' }, 403) const mutation = action === 'delete' ? api.deleteProject('host-1') : api.renameProject('host-1', 'renamed') await expect(mutation).rejects.toThrow(/403/) }) it('retains selection and gives an actionable message when project creation is rejected', async () => { projects = [ready] await store.refreshProjects() route = async () => response({ error: 'name reserved' }, 409) await expect(store.createProject('Taken')).rejects.toThrow() expect(store.getState().currentProjectId).toBe('host-1') expect(store.getState().toast).toMatch(/名称.*(其他|更换)/) expect(store.getState().toast).not.toContain('已创建') }) it('checks a denied settings save instead of resolving success', async () => { route = async () => response({ error: 'denied' }, 403) await expect(api.putProjectSettings('host-1', {})).rejects.toThrow(/403/) }) }) describe('identity-scoped state', () => { it('resets state on a successful cookie login, without relying on a component callback', async () => { projects = [ready]; tasks = [task('alice-private')] await store.refreshProjects(); await store.refreshTasks() route = async () => response({ owner: 'bob', name: 'Bob', isAdmin: false, mustChangePassword: false, passwordSet: true, sessionScope: 'normal' }) await api.login('bob', 'password-for-test') expect(store.getState().projects).toEqual([]) expect(store.getState().tasks).toEqual([]) }) it('clears old state as soon as logout starts, even while the server response is pending', async () => { projects = [ready]; tasks = [task('alice-private')] await store.refreshProjects(); await store.refreshTasks() const pending = deferred() route = async () => pending.promise const logout = api.logout() expect(store.getState().projects).toEqual([]) expect(store.getState().tasks).toEqual([]) pending.resolve(response({ ok: true })) await logout }) it('clears the previous account when the authenticated owner changes during identity refresh', async () => { await store.refreshMe() projects = [ready]; tasks = [task('alice-private')] await store.refreshProjects(); await store.refreshTasks() const previous = route projects = []; tasks = [] route = (path, init) => path === '/api/me' ? Promise.resolve(response({ owner: 'bob', name: 'Bob', auth: true })) : previous(path, init) await store.refreshMe() expect(store.getState()).toMatchObject({ me: { owner: 'bob' }, projects: [], tasks: [], currentProjectId: null, currentConvId: null }) }) it('clears projects, private tasks, previews and persisted selections on a new session', async () => { projects = [ready]; tasks = [task('alice-private')] await store.refreshProjects(); await store.refreshTasks() store.openSurface('surface-alice') store.setUI({ projectSettingsId: 'host-1', adminZoneOpen: true, activeWorkspace: 'private-workspace' }) localStorage.setItem('as.conv', 'alice-private') auth.beginSession() expect(store.getState()).toMatchObject({ projects: [], tasks: [], me: null, currentProjectId: null, currentConvId: null, artifacts: [], previewTabs: {}, previewActive: {} }) expect(store.getState().ui).toMatchObject({ lastProjectId: null, projectSettingsId: null, adminZoneOpen: false, activeWorkspace: null }) expect(localStorage.getItem('as.conv')).toBeNull() expect(JSON.parse(localStorage.getItem('as.ui') ?? '{}').lastProjectId).toBeNull() }) it('drops old project responses after switching accounts even when IDs are shared', async () => { const old = deferred() const previous = route route = (path, init) => path === '/api/projects' ? old.promise : previous(path, init) const pending = store.refreshProjects() auth.beginSession() projects = [project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' })] route = previous await store.refreshProjects() old.resolve(response([project('alice-only')])) await pending expect(store.getState().projects.map(p => p.id)).toEqual(['host-1']) expect(store.getState().currentProjectId).toBe('host-1') }) it('drops an old private task list after logout and a new session', async () => { const old = deferred() const previous = route route = (path, init) => path === '/api/conversations' ? old.promise : previous(path, init) const pending = store.refreshTasks() auth.logout() auth.beginSession() tasks = [task('bob-private')] route = previous await store.refreshTasks() old.resolve(response([task('alice-private')])) await pending expect(store.getState().tasks.map(t => t.id)).toEqual(['bob-private']) }) it('drops an old artifact response when the next account selects the same project', async () => { await store.openProject('host-1') const old = deferred() const previous = route route = (path, init) => path.endsWith('/artifacts') ? old.promise : previous(path, init) const pending = store.refreshArtifacts('host-1') auth.beginSession() route = previous await store.openProject('host-1') old.resolve(response([{ id: 'alice-artifact', projectId: 'host-1', owner: 'alice', name: 'Alice private', kind: 'file', size: 10, createdAt: '', updatedAt: '' }])) await pending expect(store.getState().artifacts).toEqual([]) }) it.each(['action', 'pin', 'dismiss', 'to-project', 'tidy'] as const)('ignores a late project card %s from the previous account on the same shared project', async operation => { projects = [ready] await store.refreshProjects() const old = deferred() const previous = route route = (path, init) => path.startsWith('/api/cards/') ? old.promise : previous(path, init) const card: ActivityCard = { id: 'alice-card', projectId: 'host-1', kind: 'progress', status: 'active', title: 'Alice private work', createdAt: '', updatedAt: '' } if (operation === 'tidy') store.upsertCard({ ...card, status: 'done' }) const pending = operation === 'action' ? store.cardAction(card, 'finish') : operation === 'pin' ? store.cardPin(card, true) : operation === 'dismiss' ? store.cardDismiss(card) : operation === 'tidy' ? store.tidyCards() : store.cardToProject(card) auth.beginSession() route = previous await store.refreshProjects() old.resolve(response({ card: { ...card, status: 'done' }, origin: { ...card, status: 'dismissed' } })) await pending expect(store.getState().projectCards).toEqual([]) expect(store.getState().cards).toEqual([]) }) it('does not reopen a previous account conversation after its project load finishes late', async () => { projects = [ready]; tasks = [task('alice-private')] await store.refreshProjects() await store.openProject('another-project') const old = deferred() const previous = route route = (path, init) => path === '/api/projects/host-1/artifacts' ? old.promise : previous(path, init) const pending = store.openTask('alice-private') auth.beginSession() tasks = [] route = previous await store.refreshProjects() old.resolve(response([])) await pending expect(store.getState().currentConvId).toBeNull() expect(requests.map(r => r.path)).not.toContain('/api/conversations/alice-private') }) it('does not resolve old task creation as success after a new login', async () => { projects = [ready] await store.refreshProjects() const old = deferred() const previous = route route = (path, init) => path === '/api/projects/host-1/conversations' ? old.promise : previous(path, init) const pending = store.createTask() auth.beginSession() route = previous await store.refreshProjects() old.resolve(response(task('alice-created'))) await expect(pending).rejects.toThrow(/登录状态/) expect(store.getState().currentConvId).toBeNull() }) it('does not return a previous account attachment ID when artifact refresh finishes after login', async () => { projects = [ready] await store.refreshProjects() const old = deferred() const started = deferred() const previous = route route = (path, init) => { if (path.endsWith('/uploads')) return Promise.resolve(response({ artifactId: 'alice-upload', filename: 'alice.txt', size: 3 })) if (path.endsWith('/artifacts')) { started.resolve(); return old.promise } return previous(path, init) } const pending = store.uploadAttachment(new File(['abc'], 'alice.txt')) await started.promise auth.beginSession() route = previous await store.refreshProjects() old.resolve(response([])) expect(await pending).toBeNull() }) }) describe('export capability identity', () => { it('drops the old identity export capability before any new command refresh', async () => { const previous = route route = async (path, init) => path === '/api/commands' ? response(['rdm.inspect.export.create', 'rdm.inspect.export.status', 'rdm.inspect.export.download'] .map(id => ({ id, enabled: true }))) : previous(path, init) await store.refreshPlugins() expect(store.getState().rdmExportAvailable).toBe(true) auth.beginSession() expect(store.getState().rdmExportAvailable).toBe(false) }) })