Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -59,20 +59,98 @@ describe('Presence Schema', () => {
findOneSpy.mockRestore();
});

it('updateHeartbeat should use findOneAndUpdate with upsert', async () => {
it('updateHeartbeat upserts when no presence exists', async () => {
const userId = createObjectId().toHexString();
const findOneSpy = jest.spyOn(Presence, 'findOne').mockResolvedValue(null);
const upserted = {
userId,
status: 'online',
preferredStatus: 'online',
lastHeartbeat: new Date(),
lastSeen: new Date(),
};
const findOneAndUpdateSpy = jest
.spyOn(Presence, 'findOneAndUpdate')
.mockResolvedValue(null);
.mockResolvedValue(upserted as never);

await Presence.updateHeartbeat(userId);
const result = await Presence.updateHeartbeat(userId);

expect(findOneSpy).toHaveBeenCalledWith({ userId });
expect(findOneAndUpdateSpy).toHaveBeenCalledWith(
{ userId },
expect.objectContaining({ status: 'online' }),
expect.objectContaining({
$set: expect.objectContaining({
lastHeartbeat: expect.any(Date),
lastSeen: expect.any(Date),
}),
$setOnInsert: expect.objectContaining({
status: 'online',
preferredStatus: 'online',
}),
}),
expect.objectContaining({ upsert: true, new: true, setDefaultsOnInsert: true })
);
expect(result).toEqual(upserted);

findOneSpy.mockRestore();
findOneAndUpdateSpy.mockRestore();
});

it('updateHeartbeat refreshes timestamps without forcing online when already present', async () => {
const userId = createObjectId().toHexString();
const save = jest.fn().mockImplementation(function (this: { status: string }) {
return Promise.resolve(this);
});
const existing = {
userId,
status: 'away',
statusMessage: 'In a meeting',
preferredStatus: 'away',
preferredStatusMessage: 'In a meeting',
lastHeartbeat: new Date('2020-01-01T00:00:00.000Z'),
lastSeen: new Date('2020-01-01T00:00:00.000Z'),
save,
};
const findOneSpy = jest.spyOn(Presence, 'findOne').mockResolvedValue(existing as never);
const findOneAndUpdateSpy = jest.spyOn(Presence, 'findOneAndUpdate');

const result = await Presence.updateHeartbeat(userId);

expect(findOneAndUpdateSpy).not.toHaveBeenCalled();
expect(save).toHaveBeenCalled();
expect(result.status).toBe('away');
expect(result.statusMessage).toBe('In a meeting');
expect(result.lastHeartbeat).toBeInstanceOf(Date);
expect(result.lastHeartbeat).not.toEqual(new Date('2020-01-01T00:00:00.000Z'));

findOneSpy.mockRestore();
findOneAndUpdateSpy.mockRestore();
});

it('updateHeartbeat restores preferred status when currently offline', async () => {
const userId = createObjectId().toHexString();
const save = jest.fn().mockImplementation(function (this: unknown) {
return Promise.resolve(this);
});
const existing = {
userId,
status: 'offline',
statusMessage: '',
preferredStatus: 'dnd',
preferredStatusMessage: 'Focusing',
lastHeartbeat: new Date('2020-01-01T00:00:00.000Z'),
lastSeen: new Date('2020-01-01T00:00:00.000Z'),
save,
};
const findOneSpy = jest.spyOn(Presence, 'findOne').mockResolvedValue(existing as never);

const result = await Presence.updateHeartbeat(userId);

expect(result.status).toBe('dnd');
expect(result.statusMessage).toBe('Focusing');
expect(save).toHaveBeenCalled();

findOneSpy.mockRestore();
});
});
});
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] },
})
);
});
});
});
Loading
Loading