-
Notifications
You must be signed in to change notification settings - Fork 235
feat: Aggregation pipeline for getting raw activity data #10498
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feat/172653-contribution-graph
Are you sure you want to change the base?
Changes from all commits
acf1b74
ae60b57
b77fea4
ba20876
d8eeb96
0435a68
6f72cc6
01591b3
fca009f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| import { | ||
| vi, describe, beforeAll, beforeEach, it, expect, | ||
| } from 'vitest'; | ||
|
|
||
| interface MockAggregate { | ||
| exec: () => Promise<any>; | ||
| } | ||
|
|
||
| describe('ContributionAggregationService', { timeout: 15000 }, () => { | ||
| let service: any; | ||
| let aggregateMockFn: ReturnType<typeof vi.fn>; | ||
|
|
||
| // --- Setup and Teardown --- | ||
| beforeAll(async() => { | ||
| aggregateMockFn = vi.fn(); | ||
|
|
||
| vi.doMock('~/server/models/activity', () => { | ||
| const ActivityMock = { | ||
| aggregate: aggregateMockFn, | ||
| }; | ||
| return { | ||
| default: ActivityMock, | ||
| }; | ||
| }); | ||
|
|
||
| vi.doMock('~/interfaces/activity', () => { | ||
| const MOCK_ACTIONS = { | ||
| ACTION_PAGE_CREATED: 'PAGE_CREATE', | ||
| ACTION_PAGE_UPDATED: 'PAGE_UPDATE', | ||
| }; | ||
| return { | ||
| ActivityLogActions: MOCK_ACTIONS, | ||
| }; | ||
| }); | ||
|
|
||
| const { ContributionAggregationService } = await import('./aggregation-service'); | ||
|
|
||
| service = new ContributionAggregationService(); | ||
| }); | ||
|
|
||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| }); | ||
|
|
||
|
|
||
| // Test Case 1: Verifies the pipeline structure and parameterization | ||
| it('should build a correctly structured 5-stage pipeline with dynamic parameters', () => { | ||
| const userId = 'user_123'; | ||
| const startDate = new Date('2025-11-01T00:00:00.000Z'); | ||
|
|
||
| const pipeline = service.buildPipeline({ userId, startDate }); | ||
|
|
||
| const matchStage = (pipeline[0] as { $match: any }).$match; | ||
| expect(matchStage.userId).toBe(userId); | ||
| expect(matchStage.timestamp.$gte).toEqual(startDate); | ||
|
|
||
| const expectedActions = ['PAGE_CREATE', 'PAGE_UPDATE']; | ||
| expect(matchStage.action.$in).toEqual(expectedActions); | ||
| expect(pipeline).toHaveLength(5); | ||
|
Check failure on line 59 in apps/app/src/server/service/contribution-graph/aggregation-service.spec.ts
|
||
| }); | ||
|
|
||
|
|
||
| // Test Case 2: Simulates the execution and verifies the final output | ||
| it('should call aggregate with the pipeline and return the result', async() => { | ||
| const userId = 'user_456'; | ||
| const startDate = new Date('2025-11-12T00:00:00.000Z'); | ||
|
|
||
| const mockDbOutput = [ | ||
| { d: '2025-11-12', c: 10 }, | ||
| { d: '2025-11-13', c: 3 }, | ||
| ]; | ||
| const mockExec = vi.fn().mockResolvedValue(mockDbOutput); | ||
| const mockAggregate: MockAggregate = { exec: mockExec }; | ||
|
|
||
| aggregateMockFn.mockReturnValue(mockAggregate); | ||
|
|
||
| const result = await service.runAggregationPipeline({ userId, startDate }).exec(); | ||
|
|
||
| expect(aggregateMockFn).toHaveBeenCalledTimes(1); | ||
| expect(mockExec).toHaveBeenCalledTimes(1); | ||
| expect(result).toEqual(mockDbOutput); | ||
| }); | ||
|
|
||
| // Test Case 3: Ensures an empty array is returned when no results are found | ||
| it('should return an empty array if no activities are found in the range', async() => { | ||
| const userId = 'user_empty'; | ||
| const startDate = new Date('2025-11-15T00:00:00.000Z'); | ||
|
|
||
| const mockExec = vi.fn().mockResolvedValue([]); | ||
| const mockAggregate: MockAggregate = { exec: mockExec }; | ||
|
|
||
| aggregateMockFn.mockReturnValue(mockAggregate); | ||
|
|
||
| const result = await service.runAggregationPipeline({ userId, startDate }).exec(); | ||
|
|
||
| expect(aggregateMockFn).toHaveBeenCalledTimes(1); | ||
| expect(result).toEqual([]); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| import type { PipelineStage, Aggregate } from 'mongoose'; | ||
|
|
||
| import { getUTCMidnightToday } from '~/features/contribution-graph/utils/contribution-graph-utils'; | ||
| import { ActivityLogActions } from '~/interfaces/activity'; | ||
| import Activity from '~/server/models/activity'; | ||
|
|
||
|
|
||
| export interface PipelineParams { | ||
| userId: string; | ||
| startDate: Date; | ||
| } | ||
|
|
||
| export class ContributionAggregationService { | ||
|
|
||
| public runAggregationPipeline(params: PipelineParams): Aggregate<any[]> { | ||
| const pipeline = this.buildPipeline(params); | ||
| const activityResults = Activity.aggregate(pipeline); | ||
|
|
||
| return activityResults; | ||
| } | ||
|
|
||
| public buildPipeline(params: PipelineParams): PipelineStage[] { | ||
| const { userId, startDate } = params; | ||
|
|
||
| const endDate = getUTCMidnightToday(); | ||
|
|
||
| const pipeline: PipelineStage[] = [ | ||
| { | ||
| // 1. Find actions for a user, with certain actions and date | ||
| $match: { | ||
| userId, | ||
| action: { $in: Object.values(ActivityLogActions) }, | ||
| timestamp: { | ||
| $gte: startDate, | ||
| $lt: endDate, | ||
| }, | ||
| }, | ||
| }, | ||
|
|
||
| // 2. Group activities by day | ||
| { | ||
| $group: { | ||
| _id: { | ||
| $dateTrunc: { | ||
| date: '$timestamp', | ||
| unit: 'day', | ||
| timezone: 'Z', | ||
| }, | ||
| }, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. クエリキャッシュのパフォーマンスが低下する可能性
|
||
| count: { $sum: 1 }, | ||
| }, | ||
| }, | ||
|
|
||
| // 3. Project the result into the minified format for caching | ||
| { | ||
| $project: { | ||
| _id: 0, | ||
| d: { | ||
| $dateToString: { | ||
| format: '%Y-%m-%d', | ||
| date: '$_id', | ||
| timezone: 'Z', | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
|
|
||
| // 4. Ensure the results are in chronological order | ||
| { | ||
| $sort: { | ||
| d: 1, | ||
| }, | ||
| }, | ||
| ]; | ||
|
|
||
| return pipeline; | ||
| } | ||
|
|
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
テストが「本質的なテスト」になっていない
「本質的なテスト」とは?:
現状では、runAggregationPipeline が呼ばれたかどうかだけをテストしており、実際の buildPipeline がどのように集計しどのような集計結果になるべきかをテストしていない