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
192 changes: 192 additions & 0 deletions quotevote-backend/__tests__/unit/resolvers/activityResolver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import mongoose from 'mongoose';
import { GraphQLError } from 'graphql';
import { activityResolver, normalizeActivityEvents } from '~/data/resolvers/activityResolver';
import Activity from '~/data/models/Activity';
import User from '~/data/models/User';
import type { GraphQLContext } from '~/types/graphql';

jest.mock('~/data/models/Activity');
jest.mock('~/data/models/User');
jest.mock('~/data/utils/logger', () => ({
logger: {
warn: jest.fn(),
info: jest.fn(),
error: jest.fn(),
},
}));

const actorId = '60d5ec49ad414d7a8d5464a0';
const profileId = '60d5ec49ad414d7a8d5464a1';

function mockContext(overrides: Partial<NonNullable<GraphQLContext['user']>> = {}): GraphQLContext {
return {
req: {} as GraphQLContext['req'],
res: {} as GraphQLContext['res'],
pubsub: {} as GraphQLContext['pubsub'],
user: {
_id: actorId,
username: 'alice',
email: '[email protected]',
admin: false,
...overrides,
} as NonNullable<GraphQLContext['user']>,
};
}

describe('normalizeActivityEvents', () => {
it('accepts ActivityEventType arrays', () => {
expect(normalizeActivityEvents(['VOTED', 'POSTED'])).toEqual(['VOTED', 'POSTED']);
});

it('parses legacy JSON array strings', () => {
expect(normalizeActivityEvents('["COMMENTED"]')).toEqual(['COMMENTED']);
});

it('drops unknown event strings', () => {
expect(normalizeActivityEvents(['VOTED', 'NOT_A_REAL_EVENT'] as string[])).toEqual(['VOTED']);
});
});

describe('activityResolver', () => {
beforeEach(() => {
jest.clearAllMocks();
});

describe('Query.activities', () => {
it('requires authentication', async () => {
await expect(
activityResolver.Query.activities(
null,
{
user_id: profileId,
limit: 10,
offset: 0,
searchKey: '',
activityEvent: ['VOTED'],
},
{ ...mockContext(), user: null }
)
).rejects.toThrow(GraphQLError);
});

it('returns paginated activities for a user', async () => {
const activityId = new mongoose.Types.ObjectId();
(Activity.countDocuments as jest.Mock).mockResolvedValue(1);
(Activity.find as jest.Mock).mockReturnValue({
sort: jest.fn().mockReturnValue({
skip: jest.fn().mockReturnValue({
limit: jest.fn().mockReturnValue({
lean: jest.fn().mockResolvedValue([
{
_id: activityId,
userId: new mongoose.Types.ObjectId(profileId),
postId: new mongoose.Types.ObjectId(),
activityType: 'VOTED',
content: 'voted',
created: new Date('2024-01-01T00:00:00Z'),
},
]),
}),
}),
}),
});

const result = await activityResolver.Query.activities(
null,
{
user_id: profileId,
limit: 15,
offset: 0,
searchKey: '',
activityEvent: ['VOTED'],
},
mockContext()
);

expect(Activity.countDocuments).toHaveBeenCalledWith(
expect.objectContaining({
userId: profileId,
activityType: { $in: ['VOTED'] },
})
);
expect(result.pagination).toEqual({ total_count: 1, limit: 15, offset: 0 });
expect(result.entities).toHaveLength(1);
expect(result.entities[0].activityType).toBe('VOTED');
expect(result.entities[0]._id).toBe(activityId.toString());
expect(result.entities[0].userId).toBe(profileId);
});

it('rejects activities missing userId', async () => {
(Activity.countDocuments as jest.Mock).mockResolvedValue(1);
(Activity.find as jest.Mock).mockReturnValue({
sort: jest.fn().mockReturnValue({
skip: jest.fn().mockReturnValue({
limit: jest.fn().mockReturnValue({
lean: jest.fn().mockResolvedValue([
{
_id: new mongoose.Types.ObjectId(),
userId: null,
activityType: 'VOTED',
created: new Date(),
},
]),
}),
}),
}),
});

await expect(
activityResolver.Query.activities(
null,
{
user_id: profileId,
limit: 10,
offset: 0,
searchKey: '',
activityEvent: ['VOTED'],
},
mockContext()
)
).rejects.toThrow(/missing required userId/);
});

it('falls back to following feed when user_id is omitted', async () => {
const followingId = '60d5ec49ad414d7a8d5464a2';
(User.findById as jest.Mock).mockReturnValue({
select: jest.fn().mockReturnValue({
lean: jest.fn().mockResolvedValue({
_followingId: [new mongoose.Types.ObjectId(followingId)],
}),
}),
});
(Activity.countDocuments as jest.Mock).mockResolvedValue(0);
(Activity.find as jest.Mock).mockReturnValue({
sort: jest.fn().mockReturnValue({
skip: jest.fn().mockReturnValue({
limit: jest.fn().mockReturnValue({
lean: jest.fn().mockResolvedValue([]),
}),
}),
}),
});

await activityResolver.Query.activities(
null,
{
user_id: '',
limit: 10,
offset: 0,
searchKey: '',
activityEvent: [],
},
mockContext()
);

expect(Activity.countDocuments).toHaveBeenCalledWith(
expect.objectContaining({
userId: { $in: [followingId] },
})
);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import Notification from '~/data/models/Notification';
import { notificationResolver } from '~/data/resolvers/notificationResolver';
import type { GraphQLContext } from '~/types/graphql';

jest.mock('~/data/models/Notification');

const userId = '60d5ec49ad414d7a8d5464a0';

function mockContext(user: GraphQLContext['user'] = null): GraphQLContext {
return {
req: {} as GraphQLContext['req'],
res: {} as GraphQLContext['res'],
pubsub: {} as GraphQLContext['pubsub'],
user,
};
}

describe('notificationResolver', () => {
beforeEach(() => {
jest.clearAllMocks();
});

describe('Query.notifications', () => {
it('requires authentication', async () => {
await expect(
notificationResolver.Query.notifications(null, {}, mockContext(null))
).rejects.toThrow(/Authentication required/);
});

it('returns an empty list when the user has no notifications', async () => {
const lean = jest.fn().mockResolvedValue([]);
const limit = jest.fn().mockReturnValue({ lean });
const sort = jest.fn().mockReturnValue({ limit });
(Notification.find as jest.Mock).mockReturnValue({ sort });

const result = await notificationResolver.Query.notifications(
null,
{},
mockContext({
_id: userId,
username: 'alice',
email: '[email protected]',
} as NonNullable<GraphQLContext['user']>)
);

expect(Notification.find).toHaveBeenCalledWith({
userId,
status: 'new',
});
expect(limit).toHaveBeenCalledWith(50);
expect(result).toEqual([]);
});

it('clamps limit to a maximum of 100', async () => {
const lean = jest.fn().mockResolvedValue([]);
const limit = jest.fn().mockReturnValue({ lean });
const sort = jest.fn().mockReturnValue({ limit });
(Notification.find as jest.Mock).mockReturnValue({ sort });

await notificationResolver.Query.notifications(
null,
{ limit: 500 },
mockContext({
_id: userId,
username: 'alice',
email: '[email protected]',
} as NonNullable<GraphQLContext['user']>)
);

expect(limit).toHaveBeenCalledWith(100);
});
});
});
3 changes: 3 additions & 0 deletions quotevote-backend/app/data/models/Activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ const ActivitySchema = new Schema<ActivityDocument, ActivityModel>(
{ timestamps: true }
);

// Supports activities feed: filter by user, sort newest-first with skip/limit.
ActivitySchema.index({ userId: 1, created: -1 });

const Activity =
(mongoose.models.Activity as ActivityModel) ||
mongoose.model<ActivityDocument, ActivityModel>('Activity', ActivitySchema);
Expand Down
Loading