fix(team): guard cross-team targets by context

This commit is contained in:
777genius 2026-05-26 18:21:23 +03:00
parent 7514bf05eb
commit 636d121f5f
2 changed files with 67 additions and 0 deletions

View file

@ -3133,11 +3133,18 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
},
fetchCrossTeamTargets: async () => {
const requestScope = captureContextRequestScope(get);
set({ crossTeamTargetsLoading: true });
try {
const targets = await api.crossTeam.listTargets();
if (!isContextRequestScopeCurrent(get, requestScope)) {
return;
}
set({ crossTeamTargets: targets, crossTeamTargetsLoading: false });
} catch (error) {
if (!isContextRequestScopeCurrent(get, requestScope)) {
return;
}
logger.error('fetchCrossTeamTargets failed', error);
set({ crossTeamTargets: [], crossTeamTargetsLoading: false });
}

View file

@ -26,6 +26,9 @@ const apiMock = vi.hoisted(() => ({
review: {
invalidateTaskChangeSummaries: vi.fn(async () => undefined),
},
crossTeam: {
listTargets: vi.fn(),
},
}));
interface TeamSummaryLike {
@ -63,6 +66,11 @@ interface TeamSnapshotLike {
processes: [];
}
interface CrossTeamTargetLike {
teamName: string;
displayName: string;
}
const teamSnapshot = (
teamName: string,
projectPath: string,
@ -176,6 +184,7 @@ describe('team slice context races', () => {
apiMock.teams.getTaskChangePresence.mockReset();
apiMock.teams.showMessageNotification.mockClear();
apiMock.review.invalidateTaskChangeSummaries.mockClear();
apiMock.crossTeam.listTargets.mockReset();
});
afterEach(() => {
@ -323,6 +332,57 @@ describe('team slice context races', () => {
expect(store.getState().globalTasksLoading).toBe(false);
});
it('ignores cross-team targets loaded for a previous context', async () => {
const store = createSliceStore();
const localTargets = deferred<CrossTeamTargetLike[]>();
apiMock.crossTeam.listTargets.mockReturnValueOnce(localTargets.promise);
const fetchPromise = store.getState().fetchCrossTeamTargets();
expect(store.getState().crossTeamTargetsLoading).toBe(true);
store.setState({
activeContextId: 'ssh-dev',
crossTeamTargets: [],
crossTeamTargetsLoading: false,
});
localTargets.resolve([
{
teamName: 'local-target',
displayName: 'Local Target',
},
]);
await fetchPromise;
expect(store.getState().crossTeamTargets).toEqual([]);
expect(store.getState().crossTeamTargetsLoading).toBe(false);
});
it('ignores cross-team targets loaded before a context epoch reset with the same context id', async () => {
const store = createSliceStore();
const localTargets = deferred<CrossTeamTargetLike[]>();
apiMock.crossTeam.listTargets.mockReturnValueOnce(localTargets.promise);
const fetchPromise = store.getState().fetchCrossTeamTargets();
expect(store.getState().crossTeamTargetsLoading).toBe(true);
invalidateContextScopedRequestEpoch();
store.setState({
activeContextId: 'local',
crossTeamTargets: [],
crossTeamTargetsLoading: false,
});
localTargets.resolve([
{
teamName: 'old-local-target',
displayName: 'Old Local Target',
},
]);
await fetchPromise;
expect(store.getState().crossTeamTargets).toEqual([]);
expect(store.getState().crossTeamTargetsLoading).toBe(false);
});
it('ignores selected team data loaded for a previous context', async () => {
const store = createSliceStore();
const localData = deferred<TeamSnapshotLike>();