Skip to content

Commit

Permalink
Add ability to sort statistics results (#78)
Browse files Browse the repository at this point in the history
* #76 Add new sort parameter to `getIndividualUserStatistics`
* #76 Add full test coverage to the statistics service
* #76 Document, refactor and provide test coverage for `quizService.quizScorePercentagesForUser`
  • Loading branch information
danielemery authored Nov 12, 2023
1 parent 625c08e commit 9b44d32
Show file tree
Hide file tree
Showing 8 changed files with 439 additions and 115 deletions.
16 changes: 15 additions & 1 deletion src/gql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ const typeDefs = gql`
NUMBER_OF_QUIZZES_COMPLETED_WITH_DESC
}
enum IndividualUserStatisticsSortOption {
QUIZZES_COMPLETED_DESC
AVERAGE_SCORE_DESC
}
type PageInfo {
hasNextPage: Boolean
startCursor: String
Expand Down Expand Up @@ -151,7 +156,16 @@ const typeDefs = gql`
"""
users(first: Int, after: String, sortedBy: UserSortOption): UserConnection
me: UserDetails
individualUserStatistics: [IndividualUserStatistic]
"""
Get statistics for every user.
Optionally sort using the sortedBy parameter.
Results from this endpoint may be delayed by up to 24 hours.
"""
individualUserStatistics(
"The sorting option to use"
sortedBy: IndividualUserStatisticsSortOption
): [IndividualUserStatistic]
}
type Mutation {
Expand Down
224 changes: 145 additions & 79 deletions src/quiz/quiz.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,115 +3,181 @@ import { QuizPersistence } from './quiz.persistence';
import { S3FileService } from '../file/s3.service';
import { Decimal } from '@prisma/client/runtime/library';

jest.mock('./quiz.persistence');

const mockPersistence = {
getQuizzesWithUserResults: jest.fn(),
getCompletionScoreWithQuizTypesForUser: jest.fn(),
};
const mockFileService = {};

const sut = new QuizService(mockPersistence as unknown as QuizPersistence, mockFileService as S3FileService);

describe('QuizService', () => {
beforeEach(() => {
jest.resetAllMocks();
});
describe('getQuizzesWithUserResults', () => {
it('must call getQuizzesWithUserResults on persistence with correct arguments and transform the result', async () => {
const persistenceResult = [
{
id: 'fake-id-one',
type: 'SHARK',
date: new Date('2023-01-01'),
uploadedAt: new Date('2023-01-02'),
uploadedByUserId: 'fake-user-id',
completions: [],
uploadedByUser: {
id: 'fake-user-id',
email: '[email protected]',
name: 'Joe Blogs',
},
},
{
id: 'fake-id-two',
type: 'BRAINWAVES',
date: new Date('2023-02-01'),
uploadedAt: new Date('2023-03-02'),
uploadedByUserId: 'fake-user-id',
completions: [
{
completedAt: new Date('2023-03-10'),
completedBy: [
{
user: {
id: 'fake-completion-user-id',
email: '[email protected]',
name: 'Completer',
},
},
],
score: new Decimal(10),
},
],
uploadedByUser: {
id: 'fake-user-id',
email: '[email protected]',
name: 'Joe Blogs',
},
},
];
mockPersistence.getQuizzesWithUserResults.mockImplementationOnce(() =>
Promise.resolve({ data: persistenceResult, hasMoreRows: false }),
);

const actual = await sut.getQuizzesWithUsersResults('[email protected]', 10);

expect(mockPersistence.getQuizzesWithUserResults).toHaveBeenCalledTimes(1);
expect(mockPersistence.getQuizzesWithUserResults).toHaveBeenCalledWith({
userEmail: '[email protected]',
limit: 10,
});

expect(actual).toEqual({
data: [
describe('quiz', () => {
describe('quiz.service', () => {
beforeEach(() => {
jest.restoreAllMocks();
});
describe('getQuizzesWithUserResults', () => {
it('must call getQuizzesWithUserResults on persistence with correct arguments and transform the result', async () => {
const persistenceResult = [
{
id: 'fake-id-one',
type: 'SHARK',
date: new Date('2023-01-01'),
uploadedAt: new Date('2023-01-02'),
uploadedBy: {
email: '[email protected]',
uploadedByUserId: 'fake-user-id',
completions: [],
uploadedByUser: {
id: 'fake-user-id',
email: '[email protected]',
name: 'Joe Blogs',
},
myCompletions: [],
},
{
id: 'fake-id-two',
type: 'BRAINWAVES',
date: new Date('2023-02-01'),
uploadedAt: new Date('2023-03-02'),
uploadedBy: {
email: '[email protected]',
id: 'fake-user-id',
name: 'Joe Blogs',
},
myCompletions: [
uploadedByUserId: 'fake-user-id',
completions: [
{
completedAt: new Date('2023-03-10'),
completedBy: [
{
id: 'fake-completion-user-id',
email: '[email protected]',
name: 'Completer',
user: {
id: 'fake-completion-user-id',
email: '[email protected]',
name: 'Completer',
},
},
],
score: 10,
score: new Decimal(10),
},
],
uploadedByUser: {
id: 'fake-user-id',
email: '[email protected]',
name: 'Joe Blogs',
},
},
],
hasMoreRows: false,
];
mockPersistence.getQuizzesWithUserResults.mockImplementationOnce(() =>
Promise.resolve({ data: persistenceResult, hasMoreRows: false }),
);

const actual = await sut.getQuizzesWithUsersResults('[email protected]', 10);

expect(mockPersistence.getQuizzesWithUserResults).toHaveBeenCalledTimes(1);
expect(mockPersistence.getQuizzesWithUserResults).toHaveBeenCalledWith({
userEmail: '[email protected]',
limit: 10,
});

expect(actual).toEqual({
data: [
{
id: 'fake-id-one',
type: 'SHARK',
date: new Date('2023-01-01'),
uploadedAt: new Date('2023-01-02'),
uploadedBy: {
email: '[email protected]',
id: 'fake-user-id',
name: 'Joe Blogs',
},
myCompletions: [],
},
{
id: 'fake-id-two',
type: 'BRAINWAVES',
date: new Date('2023-02-01'),
uploadedAt: new Date('2023-03-02'),
uploadedBy: {
email: '[email protected]',
id: 'fake-user-id',
name: 'Joe Blogs',
},
myCompletions: [
{
completedAt: new Date('2023-03-10'),
completedBy: [
{
id: 'fake-completion-user-id',
email: '[email protected]',
name: 'Completer',
},
],
score: 10,
},
],
},
],
hasMoreRows: false,
});
});
});
describe('quizScorePercentagesForUser', () => {
it('must call getCompletionScoreWithQuizTypesForUser on persistence with correct arguments and calculate percentages for the results', async () => {
mockPersistence.getCompletionScoreWithQuizTypesForUser.mockResolvedValueOnce({
data: [
{
id: '1',
quiz: {
type: 'SHARK',
},
score: new Decimal(10),
},
{
id: '2',
quiz: {
type: 'BRAINWAVES',
},
score: new Decimal(12.5),
},
],
hasMoreRows: false,
});

const actual = await sut.quizScorePercentagesForUser('[email protected]', 2, 'test-cursor');

expect(mockPersistence.getCompletionScoreWithQuizTypesForUser).toHaveBeenCalledTimes(1);
expect(mockPersistence.getCompletionScoreWithQuizTypesForUser).toHaveBeenCalledWith({
email: '[email protected]',
limit: 2,
afterId: 'test-cursor',
});

expect(actual).toEqual({
stats: [0.5, 0.25],
cursor: undefined,
});
});
it('must provide the correct cursor if more data is available', async () => {
mockPersistence.getCompletionScoreWithQuizTypesForUser.mockResolvedValueOnce({
data: [
{
id: '1',
quiz: {
type: 'SHARK',
},
score: new Decimal(10),
},
{
id: '2',
quiz: {
type: 'BRAINWAVES',
},
score: new Decimal(12.5),
},
],
hasMoreRows: true,
});

const actual = await sut.quizScorePercentagesForUser('[email protected]');

expect(actual).toEqual({
stats: [0.5, 0.25],
cursor: '2',
});
});
});
});
Expand Down
25 changes: 4 additions & 21 deletions src/quiz/quiz.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,29 +160,12 @@ export class QuizService {

/**
* Get a paginated list of quiz percentages for a user.
* @param filters Paging and filtering options.
* @param email The email of the user to get quiz percentages for.
* @param first The number of quiz percentages to get, defaults to the maximum of 100.
* @param afterId Optionally an id to use as a cursor to get quiz percentages after. This will have been provided in a previous call to this function.
* @returns A list of quiz percentages (as a number between 0 and 1) for the user in stats and a cursor to load the next set of scores.
*/
async quizScorePercentagesForUser({
email,
first = 100,
afterId,
}: {
/**
* The email of the user to get quiz percentages for.
*/
email: string;
/**
* The number of quiz percentages to get.
*/
first: number;
/**
* The cursor to start getting quiz percentages from.
* If not provided, the first quiz percentage will be returned.
* Will have been returned in the previous call to this function.
*/
afterId?: string;
}) {
async quizScorePercentagesForUser(email: string, first = 100, afterId?: string) {
const { data, hasMoreRows } = await this.#persistence.getCompletionScoreWithQuizTypesForUser({
email,
limit: first,
Expand Down
2 changes: 2 additions & 0 deletions src/statistics/statistics.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ export interface IndividualUserStatistic {
totalQuizCompletions: number;
averageScorePercentage: number;
}

export type IndividualUserStatisticsSortOption = 'QUIZZES_COMPLETED_DESC' | 'AVERAGE_SCORE_DESC';
6 changes: 3 additions & 3 deletions src/statistics/statistics.gql.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { QuizlordContext } from '..';
import { authorisationService, statisticsService } from '../service.locator';
import { IndividualUserStatistic } from './statistics.dto';
import { IndividualUserStatistic, IndividualUserStatisticsSortOption } from './statistics.dto';

async function individualUserStatistics(
_p: unknown,
_: void,
{ sortedBy }: { sortedBy?: IndividualUserStatisticsSortOption },
context: QuizlordContext,
): Promise<IndividualUserStatistic[]> {
authorisationService.requireUserRole(context, 'USER');
return statisticsService.getIndividualUserStatistics();
return statisticsService.getIndividualUserStatistics(sortedBy);
}

export const statisticsQueries = {
Expand Down
Loading

0 comments on commit 9b44d32

Please sign in to comment.