Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions backend/__tests__/service/attentionQueue.counts.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
const mongoose = require('mongoose');
const Pod = require('../../models/Pod');
const AttentionItem = require('../../models/AttentionItem');
const service = require('../../services/attentionItemService');
const { setupMongoDb, closeMongoDb, clearMongoDb } = require('../utils/testUtils');

describe('uncapped attention counts — persisted query and membership', () => {
beforeAll(setupMongoDb);
afterAll(closeMongoDb);
afterEach(clearMongoDb);

it('counts beyond 80, excludes revoked membership and other recipients, and recounts after acknowledgement', async () => {
const recipient = new mongoose.Types.ObjectId();
const other = new mongoose.Types.ObjectId();
const [busy, omitted, revoked] = await Pod.create([
{ name: 'Busy', type: 'team', createdBy: recipient, members: [recipient] },
{ name: 'Outside display cap', type: 'team', createdBy: other, members: [recipient] },
{ name: 'Revoked', type: 'team', createdBy: other, members: [other] },
]);
const make = (id, pod, kind = 'mention', who = recipient, status = 'open') => ({
recipientUserId: who, podId: pod._id, kind, status,
source: { type: kind === 'mention' ? 'message' : 'approval', id }, title: id,
createdAt: new Date('2026-09-01T00:00:00Z'),
});
await AttentionItem.insertMany([
...Array.from({ length: 90 }, (_, i) => make(`mention-${i}`, busy)),
...Array.from({ length: 4 }, (_, i) => make(`approval-${i}`, busy, 'approval')),
{ ...make('oldest', omitted), createdAt: new Date('2020-01-01') },
make('revoked', revoked), make('other-user', busy, 'mention', other),
make('closed', busy, 'mention', recipient, 'resolved'),
]);
const queue = await service.getOpenQueue(recipient);
expect(queue.count).toBe(95);
expect(queue.countsByPod).toEqual({ [busy.id]: 94, [omitted.id]: 1 });
expect(queue.items).toHaveLength(12);
expect(queue.items.filter((item) => item.kind === 'mention')).toHaveLength(8);
expect(queue.items.some((item) => item.podId === omitted.id)).toBe(false);

const old = await AttentionItem.findOne({ 'source.id': 'oldest' });
expect(await service.acknowledgeMention(other, old.id)).toMatchObject({ success: false });
expect(await service.acknowledgeMention(recipient, old.id)).toMatchObject({ success: true });
const next = await service.getOpenQueue(recipient);
expect(next.count).toBe(94);
expect(next.countsByPod).toEqual({ [busy.id]: 94 });
});

it('returns an authoritative empty shape for invalid recipients', async () => {
expect(await service.getOpenQueue('invalid')).toEqual({ items: [], count: 0, countsByPod: {}, composePodId: null });
});
});
4 changes: 2 additions & 2 deletions backend/__tests__/unit/services/attentionItemService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,10 @@ describe('attentionItemService', () => {
});

it('returns only rows whose recipient is still a member and resolves by recipient-owned id', async () => {
mockFind.mockReturnValue({ sort: () => ({ limit: () => ({ lean: async () => [
mockFind.mockReturnValue({ sort: () => ({ lean: async () => [
{ _id: 'attention-1', recipientUserId: '507f191e810c19729de860ea', podId: 'pod-1', kind: 'mention', source: { type: 'message', id: '41' }, title: 'Mention', createdAt: new Date() },
{ _id: 'attention-2', recipientUserId: '507f191e810c19729de860ea', podId: 'pod-2', kind: 'approval', source: { type: 'approval', id: 'a-1' }, title: 'Old access', createdAt: new Date() },
] }) }) });
] }) });
mockPodFind.mockReturnValue(chain([
{ _id: 'pod-1', name: 'Current', createdBy: '507f191e810c19729de860ea', members: [] },
{ _id: 'pod-2', name: 'Removed', createdBy: 'someone-else', members: [] },
Expand Down
14 changes: 10 additions & 4 deletions backend/services/attentionItemService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,12 +337,13 @@ export const resolveMany = async (sourceType: SourceType, sourceIds: unknown[]):
}
};

export const getOpenQueue = async (recipientUserId: unknown): Promise<{ items: any[]; count: number; composePodId: string | null }> => {
export const getOpenQueue = async (recipientUserId: unknown): Promise<{ items: any[]; count: number; countsByPod: Record<string, number>; composePodId: string | null }> => {
// Route callers carry a real Mongo id. Returning an empty queue for a bad
// value keeps malformed/read-only callers from turning a cast error into a
// 500 and makes the authorization boundary explicit.
if (!/^[a-f\d]{24}$/i.test(String(recipientUserId))) return { items: [], count: 0, composePodId: null };
const rows = await AttentionItem.find({ recipientUserId, status: 'open' }).sort({ createdAt: -1 }).limit(80).lean();
if (!/^[a-f\d]{24}$/i.test(String(recipientUserId))) return { items: [], count: 0, countsByPod: {}, composePodId: null };
// Counts include every accessible open item; only the rendered cards are capped.
const rows = await AttentionItem.find({ recipientUserId, status: 'open' }).sort({ createdAt: -1 }).lean();
const podIds = [...new Set(rows.map((row: any) => String(row.podId)))];
const pods = await Pod.find({ _id: { $in: podIds } }).select('_id name createdBy members').lean();
const allowed = new Map(pods.filter((pod: any) => isCurrentMember(pod, recipientUserId)).map((pod: any) => [String(pod._id), pod]));
Expand All @@ -363,7 +364,12 @@ export const getOpenQueue = async (recipientUserId: unknown): Promise<{ items: a
messageId: row.messageId, threadRootId: row.threadRootId, options: row.options || [], createdAt: row.createdAt,
});
}
return { items: picked, count: valid.length, composePodId: picked.find((row) => row.kind === 'mention')?.podId || null };
const countsByPod = valid.reduce((counts: Record<string, number>, row: any) => {
const podId = String(row.podId);
counts[podId] = (counts[podId] || 0) + 1;
return counts;
}, {});
return { items: picked, count: valid.length, countsByPod, composePodId: picked.find((row) => row.kind === 'mention')?.podId || null };
};

export const acknowledgeMention = async (recipientUserId: unknown, attentionItemId: string): Promise<{ success: boolean; error?: string }> => {
Expand Down
42 changes: 28 additions & 14 deletions frontend/src/v2/__tests__/V2ActivityPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@ const CurrentPath = () => {
return <div data-testid="current-path">{location.pathname}{location.search}</div>;
};

// The queue now arrives from /decision-queue (TASK-083) — recap.needsYou is
// only the degrade path when that endpoint fails.
// Only the queue endpoint supplies attention; recap is not a fallback.
const decisionQueue = {
items: [
{
Expand All @@ -38,6 +37,7 @@ const decisionQueue = {
},
],
count: 2,
countsByPod: { 'pod-1': 2 },
composePodId: 'pod-1',
};

Expand Down Expand Up @@ -73,13 +73,8 @@ describe('V2ActivityPage', () => {

beforeEach(async () => {
jest.clearAllMocks();
// Default: the decision-queue endpoint FAILS, exercising the designed
// degrade path (fall back to recap.needsYou) — which also keeps the
// pre-existing tests' order-based mockResolvedValueOnce chains valid,
// since their Once values feed the recap call and this implementation
// catches the queue call. The first test overrides with real items.
mockGet.mockImplementation((url: string) => {
if (url === '/api/activity/decision-queue') return Promise.reject(new Error('queue down'));
if (url === '/api/activity/decision-queue') return Promise.resolve({ data: decisionQueue });
return Promise.resolve({ data: recap });
});
await act(async () => { await i18n.changeLanguage('en'); });
Expand Down Expand Up @@ -131,31 +126,34 @@ describe('V2ActivityPage', () => {
});

test('acknowledges a mention explicitly instead of treating a feed read as acknowledgement', async () => {
mockGet
.mockResolvedValueOnce({ data: recap })
.mockResolvedValue({ data: { ...recap, needsYou: [] } });
let reads = 0;
mockGet.mockImplementation((url: string) => Promise.resolve({ data: url === '/api/activity/decision-queue'
? (++reads === 1 ? { ...decisionQueue, items: [decisionQueue.items[0]], count: 1 } : { items: [], count: 0, countsByPod: {} })
: recap }));
mockPost.mockResolvedValue({ data: { success: true } });
renderPage();

fireEvent.click(await screen.findByRole('button', { name: 'Acknowledge' }));
await waitFor(() => expect(mockPost).toHaveBeenCalledWith(
'/api/activity/mention-1/acknowledge',
'/api/activity/attention-1/acknowledge',
{},
expect.objectContaining({ headers: expect.any(Object) }),
));
expect(await screen.findByText('Nothing is waiting on you')).toBeInTheDocument();
});

test('keeps an empty Needs you state honest', async () => {
mockGet.mockResolvedValue({ data: { ...recap, needsYou: [] } });
mockGet.mockImplementation((url: string) => Promise.resolve({ data: url === '/api/activity/decision-queue'
? { items: [], count: 0, countsByPod: {} } : recap }));
renderPage();

expect(await screen.findByText('Nothing is waiting on you')).toBeInTheDocument();
expect(screen.queryByText(/0 needs you/i)).not.toBeInTheDocument();
});

test('turns a truly empty workspace into the three factual onboarding rows', async () => {
mockGet.mockResolvedValue({ data: { ...recap, needsYou: [], agents: [], board: [] } });
mockGet.mockImplementation((url: string) => Promise.resolve({ data: url === '/api/activity/decision-queue'
? { items: [], count: 0, countsByPod: {} } : { ...recap, needsYou: [], agents: [], board: [] } }));
const onGuide = jest.fn();
window.addEventListener(FIRST_RUN_REOPEN_EVENT, onGuide);
renderPage();
Expand Down Expand Up @@ -297,4 +295,20 @@ describe('V2ActivityPage', () => {
));
expect(composer).toHaveValue('');
});

test('renders the uncapped count, not the displayed card count', async () => {
mockGet.mockImplementation((url: string) => Promise.resolve({ data: url === '/api/activity/decision-queue'
? { ...decisionQueue, count: 91, countsByPod: { 'pod-1': 91 } } : recap }));
renderPage();
expect(await screen.findByLabelText('91 waiting on you')).toHaveTextContent('91');
});

test('does not substitute recap attention or claim empty on queue failure', async () => {
mockGet.mockImplementation((url: string) => url === '/api/activity/decision-queue'
? Promise.reject(new Error('queue down')) : Promise.resolve({ data: recap }));
renderPage();
expect(await screen.findByRole('status')).toBeInTheDocument();
expect(screen.queryByText('Review requested')).not.toBeInTheDocument();
expect(screen.queryByText('Nothing is waiting on you')).not.toBeInTheDocument();
});
});
2 changes: 2 additions & 0 deletions frontend/src/v2/__tests__/V2AgentBYOListenVerify.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom';
import V2AgentBYO from '../components/V2AgentBYO';
import { AuthContext } from '../../context/AuthContext';
// This suite owns the BYO request sequence, not the rail's attention query.
jest.mock('../components/V2NavRail', () => () => null);

jest.mock('axios', () => {
const mock = {
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/v2/__tests__/V2AgentBYOMemory.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom';
import V2AgentBYO from '../components/V2AgentBYO';
import { AuthContext } from '../../context/AuthContext';
// This suite owns the BYO request sequence, not the rail's attention query.
jest.mock('../components/V2NavRail', () => () => null);

jest.mock('axios', () => {
const mock = {
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/v2/__tests__/V2AgentBYOPodPicker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import V2AgentBYO from '../components/V2AgentBYO';
import { AuthContext } from '../../context/AuthContext';
// This suite owns the BYO request sequence, not the rail's attention query.
jest.mock('../components/V2NavRail', () => () => null);

jest.mock('axios', () => {
const mock = {
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/v2/__tests__/V2CommunityNav.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ describe('Community navigation', () => {
await i18nReady;
});

test('rail shows the full queue count, not a capped card count or 99+', async () => {
mockAxiosGet.mockResolvedValue({ data: { items: [], count: 105, countsByPod: { hidden: 105 } } });
renderRail();
expect(await screen.findByLabelText('105 waiting on you')).toHaveTextContent('105');
});

beforeEach(() => {
process.env.REACT_APP_COMMUNITY_POD_ID = COMMUNITY_POD_ID;
process.env.REACT_APP_COMMUNITY_INVITE_TOKEN = COMMUNITY_INVITE_TOKEN;
Expand Down
12 changes: 10 additions & 2 deletions frontend/src/v2/__tests__/V2Inspector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const renderInspector = (props: Partial<React.ComponentProps<typeof V2Inspector>
<MemoryRouter>
<V2Inspector
detail={detail as any}
attentionCount={1}
attentionItems={[{
id: 'decision-1', kind: 'decision', title: 'Slack default mode', actorName: 'Wren', podId: 'pod-1', messageId: 'message-7',
}]}
Expand Down Expand Up @@ -100,15 +101,22 @@ describe('V2Inspector', () => {

test('does not render a stale attention item from another pod', async () => {
mockGet.mockResolvedValue({ tasks: [] });
renderInspector({ attentionItems: [{ id: 'other', kind: 'decision', title: 'Other pod', podId: 'pod-2' }] });
renderInspector({ attentionCount: 0, attentionItems: [{ id: 'other', kind: 'decision', title: 'Other pod', podId: 'pod-2' }] });
await waitFor(() => expect(screen.getByText('Nothing. Wren is working.')).toBeInTheDocument());
expect(screen.queryByText('Other pod')).not.toBeInTheDocument();
});

test('uses the settled-workspace empty copy when no agent is working', async () => {
mockGet.mockImplementation(() => Promise.resolve({ items: [], tasks: [] }));
renderInspector({ detail: { ...detail, agents: [] } as any, attentionItems: [] });
renderInspector({ detail: { ...detail, agents: [] } as any, attentionCount: 0, attentionItems: [] });

expect(await screen.findByText('Nothing open.')).toBeInTheDocument();
});

test('does not claim nothing open when this pod falls outside the display cap', async () => {
renderInspector({ attentionCount: 83, attentionItems: [] });
fireEvent.click(await screen.findByRole('button', { name: '83 waiting on you' }));
expect(mockNavigate).toHaveBeenCalledWith('/v2/activity');
expect(screen.queryByText(/Nothing/)).not.toBeInTheDocument();
});
});
4 changes: 2 additions & 2 deletions frontend/src/v2/__tests__/V2PodsSidebar.community.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ const renderSidebar = (pods, selectedPodId = 'sharpen') => render(
<MemoryRouter initialEntries={['/v2/pods/sharpen']}>
<V2PodsSidebar
selectedPodId={selectedPodId}
attentionItems={[{ id: 'decision-1', kind: 'decision', title: 'Choose workspace', podId: 'sharpen' }]}
attentionCountByPod={{ sharpen: 91 }}
podsState={{
pods,
loading: false,
Expand Down Expand Up @@ -150,7 +150,7 @@ describe('V2PodsSidebar workspace groups', () => {
test('uses the decision queue count only on the selected room and retains no legacy controls', async () => {
renderSidebar([pod('sharpen', 'Sharpen', 'team', [human('me'), human('other')])]);

expect(await screen.findByLabelText('1 needs you')).toHaveTextContent('1');
expect(await screen.findByLabelText('91 needs you')).toHaveTextContent('91');
expect(screen.queryByPlaceholderText('Search pods...')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'All' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Community' })).not.toBeInTheDocument();
Expand Down
24 changes: 24 additions & 0 deletions frontend/src/v2/__tests__/useV2PodAttention.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { useV2PodAttention, notifyAttentionChanged } from '../hooks/useV2PodAttention';

const mockGet = jest.fn();
jest.mock('../hooks/useV2Api', () => ({ useV2Api: () => ({ get: mockGet }) }));

test('uses uncapped endpoint totals even when a pod has no displayed cards, and refreshes after resolution', async () => {
mockGet.mockResolvedValue({ items: [], count: 91, countsByPod: { hidden: 91 } });
const { result } = renderHook(() => useV2PodAttention());
await waitFor(() => expect(result.current.count).toBe(91));
expect(result.current.countByPod).toEqual({ hidden: 91 });
mockGet.mockResolvedValue({ items: [], count: 0, countsByPod: {} });
act(() => { notifyAttentionChanged(); });
await waitFor(() => expect(result.current.count).toBe(0));
expect(result.current.countByPod).toEqual({});
});

test('an unavailable queue is unknown, not an invented zero', async () => {
mockGet.mockRejectedValue(new Error('offline'));
const { result } = renderHook(() => useV2PodAttention());
await act(async () => {});
expect(result.current.count).toBeNull();
expect(result.current.items).toEqual([]);
});
3 changes: 3 additions & 0 deletions frontend/src/v2/__tests__/v2-layout-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,9 @@ describe('v2 layout invariants (CSS rule presence)', () => {
expect(podAttention).toContain("'/api/activity/decision-queue'");
expect(v2Layout).toContain('useV2PodAttention()');
expect(v2Layout).toContain('attentionItems={attention.items}');
expect(v2Layout).toContain('attentionCountByPod={attention.countByPod}');
expect(v2Layout).toContain('needsYouCount={attention.count}');
expect(v2Layout).toContain('attentionCount={attention.count === null ? null : (attention.countByPod[selectedPodId] || 0)}');
expect(v2Layout).toContain('needsYouCount={selectedPodId ? (attention.countByPod[selectedPodId] || 0) : 0}');
expect(podsSidebar).toContain('attentionCountByPod');
expect(podsSidebar).toContain('selected && attentionCount > 0');
Expand Down
Loading
Loading