import { EngagementEventType } from './engagement-event-type.enum'; import { EngagementService } from './engagement.service'; describe('EngagementService', () => { const userId = '507f1f77bcf86cd799439011'; const postId = '507f191e810c19729de860ea'; const setup = () => { const eventModel = { create: jest.fn().mockResolvedValue({}), aggregate: jest.fn(), }; const preferenceModel = { updateOne: jest.fn().mockResolvedValue({ acknowledged: true }), deleteOne: jest.fn().mockResolvedValue({ acknowledged: true }), find: jest.fn(), }; const postsService = { findById: jest.fn().mockResolvedValue({ id: postId }), }; const feedVersionService = { bumpUserVersion: jest.fn().mockResolvedValue(2), }; const service = new EngagementService( eventModel as any, preferenceModel as any, postsService as any, feedVersionService as any, ); return { service, eventModel, preferenceModel, postsService, feedVersionService }; }; it('records a normalized engagement event after checking post visibility', async () => { const { service, eventModel, postsService } = setup(); await expect( service.track(userId, { postId, type: EngagementEventType.WATCH, watchTimeMs: 9000, progressPercent: 72, sessionId: ' session-1 ', }), ).resolves.toEqual({ accepted: true, postId, type: EngagementEventType.WATCH }); expect(postsService.findById).toHaveBeenCalledWith(postId, userId); expect(eventModel.create).toHaveBeenCalledWith( expect.objectContaining({ userId: expect.objectContaining({}), postId: expect.objectContaining({}), type: EngagementEventType.WATCH, watchTimeMs: 9000, progressPercent: 72, sessionId: 'session-1', occurredAt: expect.any(Date), }), ); }); it('uses safe defaults for optional event measurements', async () => { const { service, eventModel } = setup(); await service.track(userId, { postId, type: EngagementEventType.IMPRESSION }); expect(eventModel.create).toHaveBeenCalledWith( expect.objectContaining({ watchTimeMs: 0, progressPercent: 0, sessionId: '' }), ); }); it('routes not-interested events through the preference workflow', async () => { const { service, eventModel, preferenceModel, feedVersionService } = setup(); await expect( service.track(userId, { postId, type: EngagementEventType.NOT_INTERESTED }), ).resolves.toEqual({ postId, notInterested: true }); expect(preferenceModel.updateOne).toHaveBeenCalledWith( expect.objectContaining({ userId: expect.anything(), postId: expect.anything() }), { $set: { notInterested: true } }, { upsert: true }, ); expect(eventModel.create).toHaveBeenCalledWith( expect.objectContaining({ type: EngagementEventType.NOT_INTERESTED }), ); expect(feedVersionService.bumpUserVersion).toHaveBeenCalledWith(userId); }); it('tracks a batch and reports every accepted item', async () => { const { service } = setup(); const events = [ { postId, type: EngagementEventType.OPEN }, { postId, type: EngagementEventType.COMPLETE }, ]; await expect(service.trackBatch(userId, events)).resolves.toMatchObject({ accepted: 2, items: [ { accepted: true, type: EngagementEventType.OPEN }, { accepted: true, type: EngagementEventType.COMPLETE }, ], }); }); it('removes a not-interested preference without requiring post visibility', async () => { const { service, preferenceModel, postsService, eventModel, feedVersionService } = setup(); await expect(service.setNotInterested(userId, postId, false)).resolves.toEqual({ postId, notInterested: false, }); expect(postsService.findById).not.toHaveBeenCalled(); expect(preferenceModel.deleteOne).toHaveBeenCalledWith( expect.objectContaining({ userId: expect.anything(), postId: expect.anything() }), ); expect(eventModel.create).not.toHaveBeenCalled(); expect(feedVersionService.bumpUserVersion).toHaveBeenCalledWith(userId); }); it('returns stored not-interested post ids as strings', async () => { const { service, preferenceModel } = setup(); const exec = jest.fn().mockResolvedValue([{ postId }]); const lean = jest.fn(() => ({ exec })); const select = jest.fn(() => ({ lean })); preferenceModel.find.mockReturnValue({ select }); await expect(service.getNotInterestedPostIds(userId)).resolves.toEqual([postId]); expect(preferenceModel.find).toHaveBeenCalledWith( expect.objectContaining({ userId: expect.anything(), notInterested: true }), ); expect(select).toHaveBeenCalledWith({ postId: 1 }); }); it('returns ranking aggregates keyed by post id', async () => { const { service, eventModel } = setup(); const row = { _id: { toString: () => postId }, watchMs: 5000, completions: 1, rewatches: 2, profileOpens: 1 }; const exec = jest.fn().mockResolvedValue([row]); eventModel.aggregate.mockReturnValue({ exec }); const result = await service.getRankingSignals(userId); expect(result).toEqual(new Map([[postId, row]])); expect(eventModel.aggregate).toHaveBeenCalledWith( expect.arrayContaining([ expect.objectContaining({ $match: expect.objectContaining({ userId: expect.anything() }) }), { $sort: { watchMs: -1 } }, { $limit: 500 }, ]), ); }); it('builds a 30-day engagement summary grouped by type', async () => { const { service, eventModel } = setup(); eventModel.aggregate.mockReturnValue({ exec: jest.fn().mockResolvedValue([ { _id: EngagementEventType.WATCH, count: 3, watchTimeMs: 12000 }, { _id: EngagementEventType.SHARE, count: 1, watchTimeMs: 0 }, ]), }); await expect(service.getMySummary(userId)).resolves.toEqual({ periodDays: 30, byType: { watch: { count: 3, watchTimeMs: 12000 }, share: { count: 1, watchTimeMs: 0 }, }, }); }); });