eigent/test/unit/store/spaceStore.test.ts

327 lines
10 KiB
TypeScript

// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import type { ServerProject } from '@/service/spaceApi';
import { getSessionPreviewSlice, usePageTabStore } from '@/store/pageTabStore';
import {
SPACE_SCHEMA_VERSION,
type Space,
type SpaceSourceType,
useSpaceStore,
} from '@/store/spaceStore';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const authStoreMock = vi.hoisted(() => ({
state: {
user_id: 2,
email: 'new@example.com',
},
}));
vi.mock('@/store/authStore', () => ({
getAuthStore: () => authStoreMock.state,
}));
vi.mock('@/api/http', () => ({
proxyFetchGet: vi.fn().mockResolvedValue({ projects: [] }),
}));
vi.mock('@/service/spaceApi', () => ({
proxyCreateSpace: vi.fn(),
proxyEnsureLegacySpace: vi.fn(),
proxyFetchSpaceProjects: vi.fn(),
proxyFetchSpaces: vi.fn(),
}));
vi.mock('@/service/workspaceApi', () => ({
reconcileWorkspaceBindings: vi.fn().mockResolvedValue(undefined),
}));
const makeSpace = (
id: string,
name: string,
sourceType: SpaceSourceType,
userId = '2',
metadata?: Space['metadata']
): Space => ({
id,
name,
userId,
sourceType,
rootPath: null,
rootFingerprint: null,
status: 'active',
schemaVersion: SPACE_SCHEMA_VERSION,
createdAt: 1,
updatedAt: 1,
metadata,
});
const makeServerProject = (
id: string,
spaceId: string,
status: ServerProject['status'] = 'active'
): ServerProject => ({
id,
user_id: '2',
space_id: spaceId,
name: `Project ${id}`,
status,
created_at: '2026-01-01T00:00:00.000Z',
updated_at: '2026-01-01T00:00:00.000Z',
});
describe('spaceStore user scoping', () => {
beforeEach(async () => {
vi.clearAllMocks();
const spaceApi = await import('@/service/spaceApi');
vi.mocked(spaceApi.proxyFetchSpaces).mockResolvedValue([]);
vi.mocked(spaceApi.proxyFetchSpaceProjects).mockResolvedValue([]);
vi.mocked(spaceApi.proxyCreateSpace).mockResolvedValue(
makeSpace('space_created', 'Untitled Space', 'blank')
);
vi.mocked(spaceApi.proxyEnsureLegacySpace).mockResolvedValue(
makeSpace('legacy_2', 'Legacy Space', 'legacy', '2', { legacy: true })
);
authStoreMock.state = {
email: 'new@example.com',
user_id: 2,
};
globalThis.electronAPI = {
...globalThis.electronAPI,
terminalDispose: vi.fn().mockResolvedValue({ success: true }),
};
usePageTabStore.setState({
sessionPreviewProjectId: null,
sessionPreviewByProject: {},
});
useSpaceStore.setState({
activeSpaceId: 'space_old_blank',
spaces: {
space_old_blank: {
id: 'space_old_blank',
name: 'Untitled Space',
userId: '1',
sourceType: 'blank',
status: 'active',
schemaVersion: SPACE_SCHEMA_VERSION,
createdAt: 1,
updatedAt: 3,
},
legacy_1: {
id: 'legacy_1',
name: 'Legacy Space',
userId: '1',
sourceType: 'legacy',
status: 'active',
schemaVersion: SPACE_SCHEMA_VERSION,
createdAt: 1,
updatedAt: 2,
metadata: { legacy: true },
},
space_new_blank: {
id: 'space_new_blank',
name: 'Untitled Space',
userId: '2',
sourceType: 'blank',
status: 'active',
schemaVersion: SPACE_SCHEMA_VERSION,
createdAt: 1,
updatedAt: 4,
},
},
lastVisitedProjectBySpace: {
space_old_blank: 'project_old',
space_new_blank: 'project_new',
},
projectsBySpaceId: {
space_old_blank: {
project_old: {
id: 'project_old',
userId: '1',
spaceId: 'space_old_blank',
name: 'Old project',
status: 'active',
createdAt: 1,
updatedAt: 1,
},
},
space_new_blank: {
project_new: {
id: 'project_new',
userId: '2',
spaceId: 'space_new_blank',
name: 'New project',
status: 'active',
createdAt: 1,
updatedAt: 1,
},
},
},
projectIdIndex: {
project_old: 'space_old_blank',
project_new: 'space_new_blank',
},
projectsSyncedAt: {
space_old_blank: 100,
legacy_1: 100,
space_new_blank: 200,
},
});
});
it('disposes project preview shells when their Space is deleted', () => {
const pageTabs = usePageTabStore.getState();
pageTabs.setSessionPreviewProject('project_old');
pageTabs.toggleSessionPreview();
pageTabs.choosePreviewTabType(
getSessionPreviewSlice(usePageTabStore.getState()).activeTabId!,
'terminal'
);
const terminal = getSessionPreviewSlice(usePageTabStore.getState()).tabs[0];
const shellId = terminal.type === 'terminal' ? terminal.shellId : undefined;
useSpaceStore.getState().deleteSpace('space_old_blank');
expect(globalThis.electronAPI.terminalDispose).toHaveBeenCalledWith(
shellId
);
expect(
usePageTabStore.getState().sessionPreviewByProject.project_old
).toBeUndefined();
});
it('removes spaces and project metadata from the previous signed-in user', () => {
useSpaceStore.getState().resetForUser(2);
const state = useSpaceStore.getState();
expect(Object.keys(state.spaces)).toEqual(['space_new_blank']);
expect(state.activeSpaceId).toBe('space_new_blank');
expect(Object.keys(state.projectsBySpaceId)).toEqual(['space_new_blank']);
expect(state.projectIdIndex).toEqual({ project_new: 'space_new_blank' });
expect(state.lastVisitedProjectBySpace).toEqual({
space_new_blank: 'project_new',
});
expect(state.projectsSyncedAt).toEqual({ space_new_blank: 200 });
});
it('hydrates new accounts with one blank space and hides empty legacy rows', async () => {
const spaceApi = await import('@/service/spaceApi');
vi.mocked(spaceApi.proxyFetchSpaces).mockResolvedValue([
makeSpace('legacy_2', 'Legacy Space', 'legacy', '2', { legacy: true }),
]);
vi.mocked(spaceApi.proxyCreateSpace).mockResolvedValue(
makeSpace('space_new_blank', 'Untitled Space', 'blank')
);
await useSpaceStore.getState().hydrateFromServer(2);
const state = useSpaceStore.getState();
expect(spaceApi.proxyFetchSpaceProjects).toHaveBeenCalledWith('legacy_2');
expect(spaceApi.proxyCreateSpace).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Untitled Space',
source_type: 'blank',
})
);
expect(Object.keys(state.spaces)).toEqual(['space_new_blank']);
expect(state.activeSpaceId).toBe('space_new_blank');
});
it('keeps the previously selected active space when hydrating existing spaces', async () => {
const spaceApi = await import('@/service/spaceApi');
vi.mocked(spaceApi.proxyFetchSpaces).mockResolvedValue([
makeSpace('space_recent', 'Recent Space', 'blank', '2'),
makeSpace('space_selected', 'Selected Space', 'blank', '2'),
]);
useSpaceStore.setState({
activeSpaceId: 'space_selected',
projectsSyncedAt: {
space_selected: Date.now(),
},
});
await useSpaceStore.getState().hydrateFromServer(2);
const state = useSpaceStore.getState();
expect(spaceApi.proxyCreateSpace).not.toHaveBeenCalled();
expect(Object.keys(state.spaces).sort()).toEqual([
'space_recent',
'space_selected',
]);
expect(state.activeSpaceId).toBe('space_selected');
});
it('creates a blank space for legacy migrations and selects it on first hydrate', async () => {
const spaceApi = await import('@/service/spaceApi');
vi.mocked(spaceApi.proxyFetchSpaces).mockResolvedValue([
makeSpace('legacy_2', 'Legacy Space', 'legacy', '2', { legacy: true }),
]);
vi.mocked(spaceApi.proxyFetchSpaceProjects).mockImplementation(
async (spaceId) =>
spaceId === 'legacy_2'
? [makeServerProject('project_legacy', 'legacy_2')]
: []
);
vi.mocked(spaceApi.proxyCreateSpace).mockResolvedValue(
makeSpace('space_migration_blank', 'Untitled Space', 'blank')
);
useSpaceStore.setState({
activeSpaceId: 'legacy_2',
});
await useSpaceStore.getState().hydrateFromServer(2);
const state = useSpaceStore.getState();
expect(spaceApi.proxyCreateSpace).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Untitled Space',
source_type: 'blank',
})
);
expect(Object.keys(state.spaces).sort()).toEqual([
'legacy_2',
'space_migration_blank',
]);
expect(state.activeSpaceId).toBe('space_migration_blank');
});
it('coalesces concurrent project syncs for the same Space', async () => {
const spaceApi = await import('@/service/spaceApi');
const { proxyFetchGet } = await import('@/api/http');
let resolveProjects: ((projects: ServerProject[]) => void) | undefined;
vi.mocked(spaceApi.proxyFetchSpaceProjects).mockReturnValue(
new Promise<ServerProject[]>((resolve) => {
resolveProjects = resolve;
})
);
const firstSync = useSpaceStore
.getState()
.syncProjectsFromServer('space_new_blank');
const secondSync = useSpaceStore
.getState()
.syncProjectsFromServer('space_new_blank');
await vi.waitFor(() => {
expect(spaceApi.proxyFetchSpaceProjects).toHaveBeenCalledTimes(1);
expect(proxyFetchGet).toHaveBeenCalledTimes(1);
});
resolveProjects?.([]);
await Promise.all([firstSync, secondSync]);
expect(spaceApi.proxyFetchSpaceProjects).toHaveBeenCalledTimes(1);
expect(proxyFetchGet).toHaveBeenCalledTimes(1);
});
});