Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,13 @@ export const getDaysDifference = (
export const getCurrentWeekStart = (date: Date = new Date()): Date => {
return startOfWeek(date, { weekStartsOn: 1 });
};

export const getUTCMidnightToday = () => {
const currentTime = new Date();

return new Date(Date.UTC(
currentTime.getUTCFullYear(),
currentTime.getUTCMonth(),
currentTime.getUTCDate()
));
}
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

View workflow job for this annotation

GitHub Actions / ci-app-test (20.x, 8.0)

src/server/service/contribution-graph/aggregation-service.spec.ts > ContributionAggregationService > should build a correctly structured 5-stage pipeline with dynamic parameters

AssertionError: expected [ { '$match': { …(3) } }, …(3) ] to have a length of 5 but got 4 - Expected + Received - 5 + 4 ❯ src/server/service/contribution-graph/aggregation-service.spec.ts:59:22

Check failure on line 59 in apps/app/src/server/service/contribution-graph/aggregation-service.spec.ts

View workflow job for this annotation

GitHub Actions / ci-app-test (20.x, 6.0)

src/server/service/contribution-graph/aggregation-service.spec.ts > ContributionAggregationService > should build a correctly structured 5-stage pipeline with dynamic parameters

AssertionError: expected [ { '$match': { …(3) } }, …(3) ] to have a length of 5 but got 4 - Expected + Received - 5 + 4 ❯ src/server/service/contribution-graph/aggregation-service.spec.ts:59:22

Check failure on line 59 in apps/app/src/server/service/contribution-graph/aggregation-service.spec.ts

View workflow job for this annotation

GitHub Actions / ci-app-test (20.x, 8.0)

src/server/service/contribution-graph/aggregation-service.spec.ts > ContributionAggregationService > should build a correctly structured 5-stage pipeline with dynamic parameters

AssertionError: expected [ { '$match': { …(3) } }, …(3) ] to have a length of 5 but got 4 - Expected + Received - 5 + 4 ❯ src/server/service/contribution-graph/aggregation-service.spec.ts:59:22

Check failure on line 59 in apps/app/src/server/service/contribution-graph/aggregation-service.spec.ts

View workflow job for this annotation

GitHub Actions / ci-app-test (20.x, 6.0)

src/server/service/contribution-graph/aggregation-service.spec.ts > ContributionAggregationService > should build a correctly structured 5-stage pipeline with dynamic parameters

AssertionError: expected [ { '$match': { …(3) } }, …(3) ] to have a length of 5 but got 4 - Expected + Received - 5 + 4 ❯ src/server/service/contribution-graph/aggregation-service.spec.ts:59:22
});


// 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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

テストが「本質的なテスト」になっていない

「本質的なテスト」とは?:

  • arrange, act, assert パターンに於いて、arrange で act, assert に都合のいいデータや mock を作っていないか?
    • 本質的でないテストでは、act, assert に都合のいいデータや mock を作っている
  • 実装を利用したテストになっているか?
    • 本質的でないテストでは、実装が変わってデグレしたとしてもテストが成功してしまう

現状では、runAggregationPipeline が呼ばれたかどうかだけをテストしており、実際の buildPipeline がどのように集計しどのような集計結果になるべきかをテストしていない

});

// 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',
},
},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

クエリキャッシュのパフォーマンスが低下する可能性

$dateTrunc を使えないか?
https://www.mongodb.com/docs/manual/reference/operator/aggregation/datetrunc/

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;
}

}
Loading