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
2 changes: 2 additions & 0 deletions backend/__tests__/unit/routes/activity.identity.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ describe('activity route identity handling', () => {
});
jest.doMock('../../../services/activityService', () => ({
getUserFeed: jest.fn(async () => ({ activities: [], hasMore: false })),
getRecap: jest.fn(async () => ({ needsYou: [], agents: [], board: [] })),
getPodFeed: jest.fn(async () => ({ activities: [], hasMore: false })),
getPendingApprovals: jest.fn(async () => []),
acknowledgeMention: jest.fn(async () => ({ success: true })),
toggleLike: jest.fn(async () => ({ success: true })),
addReply: jest.fn(async () => ({ success: true })),
approveActivity: jest.fn(async () => ({ success: true })),
Expand Down
17 changes: 17 additions & 0 deletions backend/__tests__/unit/routes/activity.read.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ jest.mock('../../../middleware/auth', () => (req, res, next) => {

jest.mock('../../../services/activityService', () => ({
getUserFeed: jest.fn(async () => ({ activities: [], hasMore: false })),
getRecap: jest.fn(async () => ({ needsYou: [], agents: [], board: [] })),
getPodFeed: jest.fn(async () => ({ activities: [], hasMore: false })),
getPendingApprovals: jest.fn(async () => []),
acknowledgeMention: jest.fn(async () => ({ success: true })),
toggleLike: jest.fn(async () => ({ success: true })),
addReply: jest.fn(async () => ({ success: true })),
approveActivity: jest.fn(async () => ({ success: true })),
Expand All @@ -34,6 +36,16 @@ describe('activity read routes', () => {
expect(ActivityService.getUnreadCount).toHaveBeenCalled();
});

it('GET /api/activity/recap validates its small fixed window vocabulary', async () => {
await request(app).get('/api/activity/recap?window=7d&podId=pod-1').expect(200);
expect(ActivityService.getRecap).toHaveBeenCalledWith('user123', {
window: '7d',
podId: 'pod-1',
});

await request(app).get('/api/activity/recap?window=month').expect(400);
});

it('POST /api/activity/mark-read with all:true calls markRead', async () => {
await request(app).post('/api/activity/mark-read').send({ all: true }).expect(200);
expect(ActivityService.markRead).toHaveBeenCalledWith('user123', expect.objectContaining({ all: true }));
Expand All @@ -42,4 +54,9 @@ describe('activity read routes', () => {
it('POST /api/activity/mark-read without args returns 400', async () => {
await request(app).post('/api/activity/mark-read').send({}).expect(400);
});

it('POST /api/activity/:id/acknowledge uses the dedicated mention acknowledgement', async () => {
await request(app).post('/api/activity/mention-1/acknowledge').expect(200);
expect(ActivityService.acknowledgeMention).toHaveBeenCalledWith('user123', 'mention-1');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
jest.mock('../../../models/pg/Message', () => ({ findByPodId: jest.fn() }));

const PGMessage = require('../../../models/pg/Message');
const ActivityService = require('../../../services/activityService');

describe('ActivityService.getMessageActivities', () => {
afterEach(() => jest.restoreAllMocks());

test('uses the persisted bot flag and preserves the Postgres pod id for an agent update', async () => {
PGMessage.findByPodId.mockResolvedValue([{
id: 'message-1',
pod_id: 'pod-1',
content: 'The verification suite passed.',
created_at: new Date(),
userId: { _id: 'agent-1', username: 'release-bot', isBot: true },
}]);
jest.spyOn(ActivityService, 'isAgentUsername').mockReturnValue(false);

const activities = await ActivityService.getMessageActivities(
['pod-1'],
new Map([['pod-1', { _id: 'pod-1', name: 'Release pod' }]]),
{ filter: 'agents' },
);

expect(activities).toEqual([expect.objectContaining({
actor: expect.objectContaining({ type: 'agent', name: 'release-bot' }),
pod: { id: 'pod-1', name: 'Release pod' },
flags: expect.objectContaining({ isAgentAction: true }),
})]);
});
});
229 changes: 229 additions & 0 deletions backend/__tests__/unit/services/activityService.recap.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
jest.mock('../../../models/Pod', () => ({ find: jest.fn() }));
jest.mock('../../../models/Task', () => ({ find: jest.fn() }));

const Pod = require('../../../models/Pod');
const Task = require('../../../models/Task');
const Activity = require('../../../models/Activity');
const User = require('../../../models/User');
const ActivityService = require('../../../services/activityService');

const ownerId = 'owner-1';
const pod = { _id: 'pod-1', name: 'Activity source pod', type: 'team' };

const podQuery = (pods) => ({
select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue(pods) }),
});

const taskQuery = (tasks) => ({
select: jest.fn().mockReturnValue({
sort: jest.fn().mockReturnValue({
limit: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue(tasks) }),
}),
}),
});

describe('ActivityService.getRecap', () => {
let spy;
let findByIdSpy;
let pendingApprovalsSpy;
let userFindByIdSpy;

beforeEach(() => {
jest.clearAllMocks();
Pod.find.mockReturnValue(podQuery([pod]));
Task.find.mockReturnValue(taskQuery([{
_id: 'board-1',
podId: 'pod-1',
taskId: 'TASK-068',
title: 'Activity tab',
status: 'claimed',
updatedAt: new Date(),
updates: [{ text: 'Implementation began.', author: 'sprint-impl', createdAt: new Date() }],
}]));
spy = jest.spyOn(ActivityService, 'getUserFeed').mockResolvedValue({
activities: [{
id: 'message-1',
type: 'message',
actor: { id: 'agent-1', name: 'sprint-impl', type: 'agent' },
action: 'posted a message',
preview: 'Checks passed.',
timestamp: new Date(),
pod: { id: 'pod-1', name: pod.name },
flags: { isAgentAction: true, isMention: true },
}],
});
pendingApprovalsSpy = jest.spyOn(ActivityService, 'getPendingApprovals').mockResolvedValue([]);
});

afterEach(() => {
spy?.mockRestore();
findByIdSpy?.mockRestore();
pendingApprovalsSpy?.mockRestore();
userFindByIdSpy?.mockRestore();
});

test('projects existing agent activity, direct mentions, and board updates without writing new events', async () => {
const result = await ActivityService.getRecap(ownerId, { window: 'today' });

expect(result.pods).toEqual([expect.objectContaining({ id: 'pod-1', name: pod.name })]);
expect(result.needsYou).toEqual([expect.objectContaining({
kind: 'mention', podId: 'pod-1', title: 'sprint-impl mentioned you',
})]);
expect(result.agents).toEqual([expect.objectContaining({
id: 'agent-1', name: 'sprint-impl', messageCount: 1,
updates: [expect.objectContaining({ content: 'Checks passed.' })],
})]);
expect(result.board).toEqual([expect.objectContaining({
taskId: 'TASK-068', title: 'Activity tab', status: 'claimed',
lastUpdate: expect.objectContaining({ text: 'Implementation began.' }),
})]);
expect(spy).toHaveBeenCalledWith(ownerId, { limit: 100 });
expect(Pod.find).toHaveBeenCalledWith(expect.objectContaining({ $or: expect.any(Array) }));
expect(Task.find).toHaveBeenCalledWith(expect.objectContaining({
podId: { $in: ['pod-1'] }, updatedAt: { $gte: expect.any(Date) },
}));
});

test('rejects a requested pod that is outside the viewer membership', async () => {
await expect(ActivityService.getRecap(ownerId, { podId: 'not-a-member-pod' }))
.rejects.toThrow('Access denied');
});

test('does not mistake the approval.status default on an ordinary message for a request', async () => {
// Build the stored document with the real schema. Its nested default is
// the production condition that caused every ordinary activity to be
// projected as an approval; a hand-written missing approval field would
// not reproduce it.
const storedMessage = new Activity({
type: 'message',
action: 'posted a message',
content: 'An ordinary update.',
});
expect(storedMessage.approval.status).toBe('pending');
spy.mockResolvedValue({
activities: [{
id: 'message-with-defaulted-approval',
type: 'message',
actor: { id: 'human-1', name: 'A human', type: 'human' },
action: 'posted a message',
preview: 'An ordinary update.',
timestamp: new Date(),
pod: { id: 'pod-1', name: pod.name },
approval: storedMessage.approval.toObject(),
flags: { isAgentAction: false, isMention: false },
}],
});

const result = await ActivityService.getRecap(ownerId, { window: 'today' });

expect(result.needsYou).toEqual([]);
});

test('keeps an actual pending approval in the decision queue', async () => {
spy.mockResolvedValue({
activities: [{
id: 'approval-1',
type: 'approval_needed',
actor: { id: 'agent-1', name: 'release-agent', type: 'agent' },
action: 'approval_needed',
preview: 'Approve access to Production.',
timestamp: new Date(),
pod: { id: 'pod-1', name: pod.name },
approval: { status: 'pending' },
flags: { isAgentAction: true, isMention: false },
}],
});

const result = await ActivityService.getRecap(ownerId, { window: 'today' });

expect(result.needsYou).toEqual([expect.objectContaining({
id: 'approval-1', kind: 'approval', title: 'Approval requested',
})]);
});

test('keeps a pending approval older than the recap window and absent from the sampled feed', async () => {
spy.mockResolvedValue({ activities: [] });
pendingApprovalsSpy.mockResolvedValue([{
_id: 'approval-before-feed-page',
type: 'approval_needed',
actor: { id: 'agent-1', name: 'release-agent', type: 'agent' },
action: 'approval_needed',
content: 'Approve a decision that has waited longer than seven days.',
podId: 'pod-1',
approval: { status: 'pending' },
createdAt: new Date('2026-08-01T00:00:00.000Z'),
}]);

const result = await ActivityService.getRecap(ownerId, { window: '7d' });

expect(result.needsYou).toEqual([expect.objectContaining({
id: 'approval-before-feed-page', kind: 'approval', podId: 'pod-1',
})]);
});

test('removes a mention only after its dedicated acknowledgement is recorded', async () => {
spy.mockResolvedValue({
acknowledgedMentionIds: ['message-1'],
activities: [{
id: 'message-1',
type: 'message',
actor: { id: 'agent-1', name: 'sprint-impl', type: 'agent' },
action: 'posted a message',
preview: 'Please review this.',
timestamp: new Date(),
pod: { id: 'pod-1', name: pod.name },
flags: { isAgentAction: true, isMention: true },
}],
});

const result = await ActivityService.getRecap(ownerId, { window: 'today' });

expect(result.needsYou).toEqual([]);
});

test('stores an acknowledgement separately from activity feed read-state', async () => {
const user = {
activityQueue: { acknowledgedMentionIds: [] },
save: jest.fn().mockResolvedValue(),
};
userFindByIdSpy = jest.spyOn(User, 'findById').mockReturnValue({
select: jest.fn().mockResolvedValue(user),
});

const result = await ActivityService.acknowledgeMention(ownerId, 'message-1');

expect(result).toEqual({ success: true, acknowledgedMentionIds: ['message-1'] });
expect(user.save).toHaveBeenCalledTimes(1);
expect(user).not.toHaveProperty('activityFeed');
});

test('projects only an approval that the existing approve writer accepts', async () => {
const storedApproval = new Activity({
type: 'approval_needed',
action: 'approval_needed',
content: 'Approve the release.',
});
const approve = jest.fn().mockResolvedValue();
storedApproval.approve = approve;
spy.mockResolvedValue({
activities: [{
id: String(storedApproval._id),
type: storedApproval.type,
actor: { id: 'agent-1', name: 'release-agent', type: 'agent' },
action: storedApproval.action,
preview: storedApproval.content,
timestamp: new Date(),
pod: { id: 'pod-1', name: pod.name },
approval: storedApproval.approval.toObject(),
flags: { isAgentAction: true, isMention: false },
}],
});
const recap = await ActivityService.getRecap(ownerId, { window: 'today' });
findByIdSpy = jest.spyOn(Activity, 'findById').mockResolvedValue(storedApproval);

const result = await ActivityService.approveActivity(recap.needsYou[0].id, ownerId, 'Approved in Activity');

expect(result).toEqual({ success: true, status: 'approved' });
expect(approve).toHaveBeenCalledWith(ownerId, 'Approved in Activity');
});
});
8 changes: 8 additions & 0 deletions backend/models/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,11 @@ export interface IUser extends Document {
lastViewedAt: Date;
readItemIds: string[];
};
// Queue acknowledgement is deliberately separate from activityFeed read
// state. Opening a feed is not the same as resolving a direct mention.
activityQueue: {
acknowledgedMentionIds: string[];
};
digestPreferences: {
enabled: boolean;
frequency: DigestFrequency;
Expand Down Expand Up @@ -333,6 +338,9 @@ const userSchema = new Schema<IUser>({
lastViewedAt: { type: Date, default: new Date(0) },
readItemIds: { type: [String], default: [] },
},
activityQueue: {
acknowledgedMentionIds: { type: [String], default: [] },
},
digestPreferences: {
enabled: { type: Boolean, default: true },
frequency: { type: String, enum: ['daily', 'weekly', 'never'], default: 'daily' },
Expand Down
Loading
Loading