diff --git a/src/gql.ts b/src/gql.ts index f385b16..4578759 100644 --- a/src/gql.ts +++ b/src/gql.ts @@ -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 @@ -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 { diff --git a/src/quiz/quiz.service.test.ts b/src/quiz/quiz.service.test.ts index 001f81c..801dead 100644 --- a/src/quiz/quiz.service.test.ts +++ b/src/quiz/quiz.service.test.ts @@ -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: 'joe@blogs.com', - 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: 'completer@fake.com', - name: 'Completer', - }, - }, - ], - score: new Decimal(10), - }, - ], - uploadedByUser: { - id: 'fake-user-id', - email: 'joe@blogs.com', - name: 'Joe Blogs', - }, - }, - ]; - mockPersistence.getQuizzesWithUserResults.mockImplementationOnce(() => - Promise.resolve({ data: persistenceResult, hasMoreRows: false }), - ); - - const actual = await sut.getQuizzesWithUsersResults('fake@fake.com', 10); - - expect(mockPersistence.getQuizzesWithUserResults).toHaveBeenCalledTimes(1); - expect(mockPersistence.getQuizzesWithUserResults).toHaveBeenCalledWith({ - userEmail: 'fake@fake.com', - 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: 'joe@blogs.com', + uploadedByUserId: 'fake-user-id', + completions: [], + uploadedByUser: { id: 'fake-user-id', + email: 'joe@blogs.com', name: 'Joe Blogs', }, - myCompletions: [], }, { id: 'fake-id-two', type: 'BRAINWAVES', date: new Date('2023-02-01'), uploadedAt: new Date('2023-03-02'), - uploadedBy: { - email: 'joe@blogs.com', - 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: 'completer@fake.com', - name: 'Completer', + user: { + id: 'fake-completion-user-id', + email: 'completer@fake.com', + name: 'Completer', + }, }, ], - score: 10, + score: new Decimal(10), }, ], + uploadedByUser: { + id: 'fake-user-id', + email: 'joe@blogs.com', + name: 'Joe Blogs', + }, }, - ], - hasMoreRows: false, + ]; + mockPersistence.getQuizzesWithUserResults.mockImplementationOnce(() => + Promise.resolve({ data: persistenceResult, hasMoreRows: false }), + ); + + const actual = await sut.getQuizzesWithUsersResults('fake@fake.com', 10); + + expect(mockPersistence.getQuizzesWithUserResults).toHaveBeenCalledTimes(1); + expect(mockPersistence.getQuizzesWithUserResults).toHaveBeenCalledWith({ + userEmail: 'fake@fake.com', + 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: 'joe@blogs.com', + 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: 'joe@blogs.com', + id: 'fake-user-id', + name: 'Joe Blogs', + }, + myCompletions: [ + { + completedAt: new Date('2023-03-10'), + completedBy: [ + { + id: 'fake-completion-user-id', + email: 'completer@fake.com', + 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('master@quizlord.net', 2, 'test-cursor'); + + expect(mockPersistence.getCompletionScoreWithQuizTypesForUser).toHaveBeenCalledTimes(1); + expect(mockPersistence.getCompletionScoreWithQuizTypesForUser).toHaveBeenCalledWith({ + email: 'master@quizlord.net', + 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('master@quizlord.net'); + + expect(actual).toEqual({ + stats: [0.5, 0.25], + cursor: '2', + }); }); }); }); diff --git a/src/quiz/quiz.service.ts b/src/quiz/quiz.service.ts index 388fe5e..40a6c33 100644 --- a/src/quiz/quiz.service.ts +++ b/src/quiz/quiz.service.ts @@ -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, diff --git a/src/statistics/statistics.dto.ts b/src/statistics/statistics.dto.ts index 3e760ad..35edc24 100644 --- a/src/statistics/statistics.dto.ts +++ b/src/statistics/statistics.dto.ts @@ -4,3 +4,5 @@ export interface IndividualUserStatistic { totalQuizCompletions: number; averageScorePercentage: number; } + +export type IndividualUserStatisticsSortOption = 'QUIZZES_COMPLETED_DESC' | 'AVERAGE_SCORE_DESC'; diff --git a/src/statistics/statistics.gql.ts b/src/statistics/statistics.gql.ts index 8318ca3..d2e1164 100644 --- a/src/statistics/statistics.gql.ts +++ b/src/statistics/statistics.gql.ts @@ -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 { authorisationService.requireUserRole(context, 'USER'); - return statisticsService.getIndividualUserStatistics(); + return statisticsService.getIndividualUserStatistics(sortedBy); } export const statisticsQueries = { diff --git a/src/statistics/statistics.service.test.ts b/src/statistics/statistics.service.test.ts new file mode 100644 index 0000000..da9df18 --- /dev/null +++ b/src/statistics/statistics.service.test.ts @@ -0,0 +1,234 @@ +import { Cache } from '../util/cache'; +import { QuizService } from '../quiz/quiz.service'; +import { UserService } from '../user/user.service'; +import { StatisticsService } from './statistics.service'; +import { IndividualUserStatisticsSortOption } from './statistics.dto'; + +const mockUserService = { + getUsers: jest.fn(), +}; +const mockQuizService = { + quizScorePercentagesForUser: jest.fn(), +}; +const mockCache = { + getItem: jest.fn(), + setItem: jest.fn(), +}; + +describe('statistics', () => { + describe('statistics.service', () => { + const sut = new StatisticsService( + mockUserService as unknown as UserService, + mockQuizService as unknown as QuizService, + mockCache as unknown as Cache, + ); + beforeEach(() => { + jest.restoreAllMocks(); + }); + describe('getIndividualUserStatistics', () => { + const fakeResultOne = { + email: 'quizmaster@quizlord.net', + totalQuizCompletions: 101, + averageScorePercentage: 0.99, + }; + const fakeResultTwo = { + email: 'quiznoob@quizlord.net', + totalQuizCompletions: 1, + averageScorePercentage: 0.01, + }; + it('must load from the cache if data present there and sort the contents by number of quizzes completed if no sort argument is passed', async () => { + mockCache.getItem.mockResolvedValueOnce([fakeResultOne, fakeResultTwo]); + + const mockSortIndividualUserStatistics = jest + .spyOn(sut, 'sortIndividualUserStatistics') + .mockReturnValueOnce([fakeResultTwo, fakeResultOne]); + + const actual = await sut.getIndividualUserStatistics(); + + expect(mockCache.getItem).toHaveBeenCalledTimes(1); + expect(mockCache.getItem).toHaveBeenCalledWith('invidual-user-statistics'); + + expect(mockSortIndividualUserStatistics).toHaveBeenCalledTimes(1); + expect(mockSortIndividualUserStatistics).toHaveBeenCalledWith( + [fakeResultOne, fakeResultTwo], + 'QUIZZES_COMPLETED_DESC', + ); + + expect(actual).toEqual([fakeResultTwo, fakeResultOne]); + }); + it('must sort by sortedBy argument if present', async () => { + mockCache.getItem.mockResolvedValueOnce([fakeResultOne, fakeResultTwo]); + + const mockSortIndividualUserStatistics = jest + .spyOn(sut, 'sortIndividualUserStatistics') + .mockReturnValueOnce([fakeResultTwo, fakeResultOne]); + + await sut.getIndividualUserStatistics('AVERAGE_SCORE_DESC'); + + expect(mockSortIndividualUserStatistics).toHaveBeenCalledTimes(1); + expect(mockSortIndividualUserStatistics).toHaveBeenCalledWith( + [fakeResultOne, fakeResultTwo], + 'AVERAGE_SCORE_DESC', + ); + }); + it('must load statistics for all users across multiple pages if there is a cache miss, and sort the results', async () => { + const expected = [ + { + email: 'userOne@quizlord.net', + name: 'One', + totalQuizCompletions: 7, + averageScorePercentage: 0.2, + }, + { + email: 'userTwo@quizlord.net', + name: undefined, + totalQuizCompletions: 7, + averageScorePercentage: 0.2, + }, + { + email: 'userThree@quizlord.net', + name: 'Three', + totalQuizCompletions: 7, + averageScorePercentage: 0.2, + }, + ]; + + mockUserService.getUsers + .mockResolvedValueOnce({ + data: [ + { + id: '1', + email: 'userOne@quizlord.net', + name: 'One', + }, + { + id: '75', + email: 'userTwo@quizlord.net', + }, + ], + hasMoreRows: true, + }) + .mockResolvedValueOnce({ + data: [ + { + id: '45', + email: 'userThree@quizlord.net', + name: 'Three', + }, + ], + hasMoreRows: false, + }); + + const mockGetStatisticsForUser = jest + .spyOn(sut, 'getStatisticsForUser') + .mockResolvedValue({ totalQuizCompletions: 7, averageScorePercentage: 0.2 }); + + const mockSortIndividualUserStatistics = jest + .spyOn(sut, 'sortIndividualUserStatistics') + .mockReturnValueOnce(expected); + + const actual = await sut.getIndividualUserStatistics(); + + expect(mockUserService.getUsers).toHaveBeenCalledTimes(2); + expect(mockUserService.getUsers).toHaveBeenNthCalledWith(1, { + currentUserId: '1', + first: 100, + afterId: undefined, + sortedBy: 'EMAIL_ASC', + }); + expect(mockUserService.getUsers).toHaveBeenNthCalledWith(2, { + currentUserId: '1', + first: 100, + afterId: '75', + sortedBy: 'EMAIL_ASC', + }); + + expect(mockGetStatisticsForUser).toHaveBeenCalledTimes(3); + expect(mockGetStatisticsForUser).toHaveBeenNthCalledWith(1, 'userOne@quizlord.net'); + expect(mockGetStatisticsForUser).toHaveBeenNthCalledWith(2, 'userTwo@quizlord.net'); + expect(mockGetStatisticsForUser).toHaveBeenNthCalledWith(3, 'userThree@quizlord.net'); + + expect(mockSortIndividualUserStatistics).toHaveBeenCalledTimes(1); + expect(mockSortIndividualUserStatistics).toHaveBeenCalledWith(expected, 'QUIZZES_COMPLETED_DESC'); + + expect(actual).toEqual(expected); + }); + }); + describe('sortIndividualUserStatistics', () => { + const quizMaster = { + email: 'quizmaster@quizlord.net', + totalQuizCompletions: 101, + averageScorePercentage: 0.99, + }; + const quizNoob = { + email: 'quiznoob@quizlord.net', + totalQuizCompletions: 1, + averageScorePercentage: 0.01, + }; + const quizMiddler = { + email: 'middlingquizzer@quizlord.net', + totalQuizCompletions: 150, + averageScorePercentage: 0.5, + }; + it('must sort by number of quizzes completed if the QUIZZES_COMPLETED_DESC sort option is provided', () => { + const actual = sut.sortIndividualUserStatistics([quizMaster, quizNoob, quizMiddler], 'QUIZZES_COMPLETED_DESC'); + + expect(actual).toEqual([quizMiddler, quizMaster, quizNoob]); + }); + it('must sort by average score if the AVERAGE_SCORE_DESC sort option is provided', () => { + const actual = sut.sortIndividualUserStatistics([quizMaster, quizNoob, quizMiddler], 'AVERAGE_SCORE_DESC'); + + expect(actual).toEqual([quizMaster, quizMiddler, quizNoob]); + }); + it('must return the statistics unsorted and log a warning if an unknown sort option is provided', () => { + const actual = sut.sortIndividualUserStatistics( + [quizMaster, quizNoob, quizMiddler], + 'UNKNOWN_SORT_OPTION' as unknown as IndividualUserStatisticsSortOption, + ); + + expect(actual).toEqual([quizMaster, quizNoob, quizMiddler]); + }); + }); + describe('getStatisticsForUser', () => { + it('must load quiz completions page by page for the user and return the number of completions and average score', async () => { + mockQuizService.quizScorePercentagesForUser + .mockResolvedValueOnce({ + stats: [0.25, 0.75], + cursor: 'fake-cursor', + }) + .mockResolvedValueOnce({ + stats: [0.5, 0.5], + cursor: undefined, + }); + + const actual = await sut.getStatisticsForUser('master@quizlord.net'); + + expect(mockQuizService.quizScorePercentagesForUser).toHaveBeenCalledTimes(2); + expect(mockQuizService.quizScorePercentagesForUser).toHaveBeenNthCalledWith( + 1, + 'master@quizlord.net', + 100, + undefined, + ); + expect(mockQuizService.quizScorePercentagesForUser).toHaveBeenNthCalledWith( + 2, + 'master@quizlord.net', + 100, + 'fake-cursor', + ); + + expect(actual).toEqual({ totalQuizCompletions: 4, averageScorePercentage: 0.5 }); + }); + it('must return 0 completions and 0 average score if the user has no completions', async () => { + mockQuizService.quizScorePercentagesForUser.mockResolvedValueOnce({ + stats: [], + cursor: undefined, + }); + + const actual = await sut.getStatisticsForUser('master@quizlord.net'); + + expect(actual).toEqual({ totalQuizCompletions: 0, averageScorePercentage: 0 }); + }); + }); + }); +}); diff --git a/src/statistics/statistics.service.ts b/src/statistics/statistics.service.ts index 4b2b9b0..265cb5f 100644 --- a/src/statistics/statistics.service.ts +++ b/src/statistics/statistics.service.ts @@ -1,7 +1,7 @@ import { QuizService } from '../quiz/quiz.service'; import { UserService } from '../user/user.service'; import { Cache } from '../util/cache'; -import { IndividualUserStatistic } from './statistics.dto'; +import { IndividualUserStatistic, IndividualUserStatisticsSortOption } from './statistics.dto'; const INDIVIDUAL_STATISTICS_CACHE_KEY = 'invidual-user-statistics'; const INDIVIDUAL_STATISTICS_CACHE_TTL = 60 * 60 * 1000; // 24 hours @@ -18,14 +18,17 @@ export class StatisticsService { /** * Gets the individual statistics for all users. + * @param sortedBy The sorting option to use. * @returns An array of users with their statistics. * * @tags worker */ - async getIndividualUserStatistics(): Promise { + async getIndividualUserStatistics( + sortedBy: IndividualUserStatisticsSortOption = 'QUIZZES_COMPLETED_DESC', + ): Promise { const cachedResult = await this.#cache.getItem(INDIVIDUAL_STATISTICS_CACHE_KEY); if (cachedResult) { - return cachedResult; + return this.sortIndividualUserStatistics(cachedResult, sortedBy); } const results: IndividualUserStatistic[] = []; @@ -40,7 +43,7 @@ export class StatisticsService { }); for (const user of data) { - const { totalQuizCompletions, averageScorePercentage } = await this.#getStatisticsForUser(user.email); + const { totalQuizCompletions, averageScorePercentage } = await this.getStatisticsForUser(user.email); results.push({ email: user.email, name: user.name, @@ -54,7 +57,27 @@ export class StatisticsService { } await this.#cache.setItem(INDIVIDUAL_STATISTICS_CACHE_KEY, results, INDIVIDUAL_STATISTICS_CACHE_TTL); - return results; + return this.sortIndividualUserStatistics(results, sortedBy); + } + + /** + * Sorts the individual user statistics by the provided option. + * @param statistics The statistics to sort. + * @param sortedBy The sorting option to use. + * @returns The sorted statistics. + */ + sortIndividualUserStatistics( + statistics: IndividualUserStatistic[], + sortedBy: IndividualUserStatisticsSortOption, + ): IndividualUserStatistic[] { + switch (sortedBy) { + case 'QUIZZES_COMPLETED_DESC': + return statistics.sort((a, b) => b.totalQuizCompletions - a.totalQuizCompletions); + case 'AVERAGE_SCORE_DESC': + return statistics.sort((a, b) => b.averageScorePercentage - a.averageScorePercentage); + default: + return statistics; + } } /** @@ -64,16 +87,16 @@ export class StatisticsService { * * @tags worker */ - async #getStatisticsForUser(userEmail: string) { + async getStatisticsForUser(userEmail: string) { let hasMoreRows = true; let cursor: string | undefined = undefined; const completionsScores: number[] = []; while (hasMoreRows) { - const { stats, cursor: latestCursor } = await this.#quizService.quizScorePercentagesForUser({ - email: userEmail, - first: 100, - afterId: cursor, - }); + const { stats, cursor: latestCursor } = await this.#quizService.quizScorePercentagesForUser( + userEmail, + 100, + cursor, + ); completionsScores.push(...stats); diff --git a/src/user/user.service.ts b/src/user/user.service.ts index 59d44b4..0649bc6 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -34,6 +34,8 @@ export class UserService { return { roles: user.roles.map((r) => r.role), id: user.id }; } + // TODO overload this function to only require the currentUserId parameter when the sortedBy parameter is + // NUMBER_OF_QUIZZES_COMPLETED_WITH_DESC async getUsers({ currentUserId, first,