diff --git a/backend/src/graphql/queries/rewardTrophyBadge.ts b/backend/src/graphql/queries/rewardTrophyBadge.ts new file mode 100644 index 000000000..dda869d7f --- /dev/null +++ b/backend/src/graphql/queries/rewardTrophyBadge.ts @@ -0,0 +1,20 @@ +import gql from 'graphql-tag' + +export const rewardTrophyBadge = gql` + mutation rewardTrophyBadge($badgeId: ID!, $userId: ID!) { + rewardTrophyBadge(badgeId: $badgeId, userId: $userId) { + id + badgeVerification { + id + isDefault + } + badgeTrophiesCount + badgeTrophies { + id + } + badgeTrophiesSelected { + id + } + } + } +` diff --git a/backend/src/graphql/queries/setTrophyBadgeSelected.ts b/backend/src/graphql/queries/setTrophyBadgeSelected.ts new file mode 100644 index 000000000..62450809f --- /dev/null +++ b/backend/src/graphql/queries/setTrophyBadgeSelected.ts @@ -0,0 +1,17 @@ +import gql from 'graphql-tag' + +export const setTrophyBadgeSelected = gql` + mutation setTrophyBadgeSelected($slot: Int!, $badgeId: ID) { + setTrophyBadgeSelected(slot: $slot, badgeId: $badgeId) { + badgeTrophiesCount + badgeTrophiesSelected { + id + isDefault + } + badgeTrophiesUnused { + id + } + badgeTrophiesUnusedCount + } + } +` diff --git a/backend/src/graphql/resolvers/badges.spec.ts b/backend/src/graphql/resolvers/badges.spec.ts index dd0cf4730..6ebed7990 100644 --- a/backend/src/graphql/resolvers/badges.spec.ts +++ b/backend/src/graphql/resolvers/badges.spec.ts @@ -5,8 +5,11 @@ import { ApolloServer } from 'apollo-server-express' import { createTestClient } from 'apollo-server-testing' import gql from 'graphql-tag' +import { TROPHY_BADGES_SELECTED_MAX } from '@constants/badges' import databaseContext from '@context/database' import Factory, { cleanDatabase } from '@db/factories' +import { rewardTrophyBadge } from '@graphql/queries/rewardTrophyBadge' +import { setTrophyBadgeSelected } from '@graphql/queries/setTrophyBadgeSelected' import createServer, { getContext } from '@src/server' let regularUser, administrator, moderator, badge, verification @@ -295,27 +298,10 @@ describe('Badges', () => { userId: 'regular-user-id', } - const rewardTrophyBadgeMutation = gql` - mutation ($badgeId: ID!, $userId: ID!) { - rewardTrophyBadge(badgeId: $badgeId, userId: $userId) { - id - badgeVerification { - id - isDefault - } - badgeTrophies { - id - } - } - } - ` - describe('unauthenticated', () => { it('throws authorization error', async () => { authenticatedUser = null - await expect( - mutate({ mutation: rewardTrophyBadgeMutation, variables }), - ).resolves.toMatchObject({ + await expect(mutate({ mutation: rewardTrophyBadge, variables })).resolves.toMatchObject({ data: { rewardTrophyBadge: null }, errors: [{ message: 'Not Authorized!' }], }) @@ -329,9 +315,7 @@ describe('Badges', () => { describe('rewards badge to user', () => { it('throws authorization error', async () => { - await expect( - mutate({ mutation: rewardTrophyBadgeMutation, variables }), - ).resolves.toMatchObject({ + await expect(mutate({ mutation: rewardTrophyBadge, variables })).resolves.toMatchObject({ data: { rewardTrophyBadge: null }, errors: [{ message: 'Not Authorized!' }], }) @@ -348,7 +332,7 @@ describe('Badges', () => { it('rejects with an informative error message', async () => { await expect( mutate({ - mutation: rewardTrophyBadgeMutation, + mutation: rewardTrophyBadge, variables: { userId: 'regular-user-id', badgeId: 'non-existent-badge-id' }, }), ).resolves.toMatchObject({ @@ -356,7 +340,7 @@ describe('Badges', () => { errors: [ { message: - 'Error: Could not reward badge! Ensure the user and the badge exist and the badge is of the correct type.', + 'Could not reward badge! Ensure the user and the badge exist and the badge is of the correct type.', }, ], }) @@ -367,7 +351,7 @@ describe('Badges', () => { it('rejects with a telling error message', async () => { await expect( mutate({ - mutation: rewardTrophyBadgeMutation, + mutation: rewardTrophyBadge, variables: { userId: 'non-existent-user-id', badgeId: 'trophy_rhino' }, }), ).resolves.toMatchObject({ @@ -375,7 +359,7 @@ describe('Badges', () => { errors: [ { message: - 'Error: Could not reward badge! Ensure the user and the badge exist and the badge is of the correct type.', + 'Could not reward badge! Ensure the user and the badge exist and the badge is of the correct type.', }, ], }) @@ -386,7 +370,7 @@ describe('Badges', () => { it('rejects with a telling error message', async () => { await expect( mutate({ - mutation: rewardTrophyBadgeMutation, + mutation: rewardTrophyBadge, variables: { userId: 'regular-user-id', badgeId: 'verification_moderator' }, }), ).resolves.toMatchObject({ @@ -394,7 +378,7 @@ describe('Badges', () => { errors: [ { message: - 'Error: Could not reward badge! Ensure the user and the badge exist and the badge is of the correct type.', + 'Could not reward badge! Ensure the user and the badge exist and the badge is of the correct type.', }, ], }) @@ -402,19 +386,43 @@ describe('Badges', () => { }) it('rewards a badge to the user', async () => { - const expected = { + await expect(mutate({ mutation: rewardTrophyBadge, variables })).resolves.toMatchObject({ data: { rewardTrophyBadge: { id: 'regular-user-id', badgeVerification: { id: 'default_verification', isDefault: true }, badgeTrophies: [{ id: 'trophy_rhino' }], + badgeTrophiesSelected: [ + { id: 'trophy_rhino' }, + { + id: 'default_trophy', + }, + { + id: 'default_trophy', + }, + { + id: 'default_trophy', + }, + { + id: 'default_trophy', + }, + { + id: 'default_trophy', + }, + { + id: 'default_trophy', + }, + { + id: 'default_trophy', + }, + { + id: 'default_trophy', + }, + ], }, }, errors: undefined, - } - await expect( - mutate({ mutation: rewardTrophyBadgeMutation, variables }), - ).resolves.toMatchObject(expected) + }) }) it('rewards a second different badge to the same user', async () => { @@ -424,44 +432,269 @@ describe('Badges', () => { description: 'You earned a racoon', icon: '/img/badges/trophy_blue_racoon.svg', }) - const trophies = [{ id: 'trophy_racoon' }, { id: 'trophy_rhino' }] - const expected = { + await mutate({ + mutation: rewardTrophyBadge, + variables: { + userId: 'regular-user-id', + badgeId: 'trophy_racoon', + }, + }) + await expect( + mutate({ + mutation: rewardTrophyBadge, + variables: { + userId: 'regular-user-id', + badgeId: 'trophy_rhino', + }, + }), + ).resolves.toMatchObject({ data: { rewardTrophyBadge: { id: 'regular-user-id', - badgeTrophies: expect.arrayContaining(trophies), + badgeTrophies: expect.arrayContaining([ + { id: 'trophy_racoon' }, + { id: 'trophy_rhino' }, + ]), + badgeTrophiesSelected: [ + { id: 'trophy_racoon' }, + { + id: 'trophy_rhino', + }, + { + id: 'default_trophy', + }, + { + id: 'default_trophy', + }, + { + id: 'default_trophy', + }, + { + id: 'default_trophy', + }, + { + id: 'default_trophy', + }, + { + id: 'default_trophy', + }, + { + id: 'default_trophy', + }, + ], }, }, errors: undefined, - } + }) + }) + + it('does not select a badge again when already rewarded and unselected by the user', async () => { + await Factory.build('badge', { + id: 'trophy_racoon', + type: 'trophy', + description: 'You earned a racoon', + icon: '/img/badges/trophy_blue_racoon.svg', + }) await mutate({ - mutation: rewardTrophyBadgeMutation, + mutation: rewardTrophyBadge, variables: { userId: 'regular-user-id', badgeId: 'trophy_rhino', }, }) + await mutate({ + mutation: rewardTrophyBadge, + variables: { + userId: 'regular-user-id', + badgeId: 'trophy_racoon', + }, + }) + authenticatedUser = await regularUser.toJson() + await mutate({ + mutation: setTrophyBadgeSelected, + variables: { + slot: 0, + badgeId: null, + }, + }) + authenticatedUser = await administrator.toJson() await expect( mutate({ - mutation: rewardTrophyBadgeMutation, + mutation: rewardTrophyBadge, + variables: { + userId: 'regular-user-id', + badgeId: 'trophy_rhino', + }, + }), + ).resolves.toMatchObject({ + data: { + rewardTrophyBadge: { + id: 'regular-user-id', + badgeTrophies: expect.arrayContaining([ + { id: 'trophy_racoon' }, + { id: 'trophy_rhino' }, + ]), + badgeTrophiesSelected: [ + { + id: 'default_trophy', + }, + { id: 'trophy_racoon' }, + { + id: 'default_trophy', + }, + { + id: 'default_trophy', + }, + { + id: 'default_trophy', + }, + { + id: 'default_trophy', + }, + { + id: 'default_trophy', + }, + { + id: 'default_trophy', + }, + { + id: 'default_trophy', + }, + ], + }, + }, + errors: undefined, + }) + }) + + it('does fill gaps in the selection array when rewarding new badges', async () => { + await Factory.build('badge', { + id: 'trophy_racoon', + type: 'trophy', + description: 'You earned a racoon', + icon: '/img/badges/trophy_blue_racoon.svg', + }) + await mutate({ + mutation: rewardTrophyBadge, + variables: { + userId: 'regular-user-id', + badgeId: 'trophy_rhino', + }, + }) + authenticatedUser = await regularUser.toJson() + await mutate({ + mutation: setTrophyBadgeSelected, + variables: { + slot: 1, + badgeId: 'trophy_rhino', + }, + }) + authenticatedUser = await administrator.toJson() + await expect( + mutate({ + mutation: rewardTrophyBadge, variables: { userId: 'regular-user-id', badgeId: 'trophy_racoon', }, }), - ).resolves.toMatchObject(expected) - }) - - it('rewards the same badge as well to another user', async () => { - const expected = { + ).resolves.toMatchObject({ data: { rewardTrophyBadge: { - id: 'regular-user-2-id', - badgeTrophies: [{ id: 'trophy_rhino' }], + id: 'regular-user-id', + badgeTrophies: expect.arrayContaining([ + { id: 'trophy_racoon' }, + { id: 'trophy_rhino' }, + ]), + badgeTrophiesSelected: [ + { id: 'trophy_racoon' }, + { id: 'trophy_rhino' }, + { + id: 'default_trophy', + }, + { + id: 'default_trophy', + }, + { + id: 'default_trophy', + }, + { + id: 'default_trophy', + }, + { + id: 'default_trophy', + }, + { + id: 'default_trophy', + }, + { + id: 'default_trophy', + }, + ], }, }, errors: undefined, + }) + }) + + it('does not select badge when maximum selected are already reached', async () => { + for (let i = 0; i < TROPHY_BADGES_SELECTED_MAX; i++) { + await Factory.build('badge', { + id: `trophy_${i}`, + type: 'trophy', + description: `You earned a ${i}`, + icon: `/img/badges/trophy_blue_${i}.svg`, + }) + await mutate({ + mutation: rewardTrophyBadge, + variables: { + userId: 'regular-user-id', + badgeId: `trophy_${i}`, + }, + }) } + await expect( + mutate({ + mutation: rewardTrophyBadge, + variables: { + userId: 'regular-user-id', + badgeId: 'trophy_rhino', + }, + }), + ).resolves.toMatchObject({ + data: { + rewardTrophyBadge: { + id: 'regular-user-id', + badgeTrophies: expect.arrayContaining([ + { id: 'trophy_0' }, + { id: 'trophy_1' }, + { id: 'trophy_2' }, + { id: 'trophy_3' }, + { id: 'trophy_4' }, + { id: 'trophy_5' }, + { id: 'trophy_6' }, + { id: 'trophy_7' }, + { id: 'trophy_8' }, + { id: 'trophy_rhino' }, + ]), + badgeTrophiesSelected: [ + { id: 'trophy_0' }, + { id: 'trophy_1' }, + { id: 'trophy_2' }, + { id: 'trophy_3' }, + { id: 'trophy_4' }, + { id: 'trophy_5' }, + { id: 'trophy_6' }, + { id: 'trophy_7' }, + { id: 'trophy_8' }, + ], + }, + }, + errors: undefined, + }) + }) + + it('rewards the same badge as well to another user', async () => { await Factory.build( 'user', { @@ -472,46 +705,102 @@ describe('Badges', () => { }, ) await mutate({ - mutation: rewardTrophyBadgeMutation, + mutation: rewardTrophyBadge, variables, }) await expect( mutate({ - mutation: rewardTrophyBadgeMutation, + mutation: rewardTrophyBadge, variables: { userId: 'regular-user-2-id', badgeId: 'trophy_rhino', }, }), - ).resolves.toMatchObject(expected) + ).resolves.toMatchObject({ + data: { + rewardTrophyBadge: { + id: 'regular-user-2-id', + badgeTrophies: [{ id: 'trophy_rhino' }], + badgeTrophiesSelected: [ + { id: 'trophy_rhino' }, + { + id: 'default_trophy', + }, + { + id: 'default_trophy', + }, + { + id: 'default_trophy', + }, + { + id: 'default_trophy', + }, + { + id: 'default_trophy', + }, + { + id: 'default_trophy', + }, + { + id: 'default_trophy', + }, + { + id: 'default_trophy', + }, + ], + }, + }, + errors: undefined, + }) }) it('creates no duplicate reward relationships', async () => { await mutate({ - mutation: rewardTrophyBadgeMutation, + mutation: rewardTrophyBadge, variables, }) - await mutate({ - mutation: rewardTrophyBadgeMutation, - variables, - }) - - const userQuery = gql` - { - User(id: "regular-user-id") { - badgeTrophiesCount - badgeTrophies { - id - } - } - } - ` - const expected = { - data: { User: [{ badgeTrophiesCount: 1, badgeTrophies: [{ id: 'trophy_rhino' }] }] }, + await expect( + mutate({ + mutation: rewardTrophyBadge, + variables, + }), + ).resolves.toMatchObject({ + data: { + rewardTrophyBadge: { + id: 'regular-user-id', + badgeTrophiesCount: 1, + badgeTrophies: [{ id: 'trophy_rhino' }], + badgeTrophiesSelected: [ + { id: 'trophy_rhino' }, + { + id: 'default_trophy', + }, + { + id: 'default_trophy', + }, + { + id: 'default_trophy', + }, + { + id: 'default_trophy', + }, + { + id: 'default_trophy', + }, + { + id: 'default_trophy', + }, + { + id: 'default_trophy', + }, + { + id: 'default_trophy', + }, + ], + }, + }, errors: undefined, - } - - await expect(query({ query: userQuery })).resolves.toMatchObject(expected) + }) }) }) }) diff --git a/backend/src/graphql/resolvers/badges.ts b/backend/src/graphql/resolvers/badges.ts index 7c107e42c..700e18d89 100644 --- a/backend/src/graphql/resolvers/badges.ts +++ b/backend/src/graphql/resolvers/badges.ts @@ -6,6 +6,9 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { neo4jgraphql } from 'neo4j-graphql-js' +import { TROPHY_BADGES_SELECTED_MAX } from '@constants/badges' +import { Context } from '@src/server' + export const defaultTrophyBadge = { id: 'default_trophy', type: 'trophy', @@ -71,44 +74,63 @@ export default { } }, - rewardTrophyBadge: async (_object, args, context, _resolveInfo) => { + rewardTrophyBadge: async (_object, args, context: Context, _resolveInfo) => { const { user: { id: currentUserId }, } = context const { badgeId, userId } = args - const session = context.driver.session() - const writeTxResultPromise = session.writeTransaction(async (transaction) => { - const response = await transaction.run( - ` - MATCH (badge:Badge {id: $badgeId, type: 'trophy'}), (user:User {id: $userId}) - MERGE (badge)-[relation:REWARDED {by: $currentUserId}]->(user) - RETURN relation, user {.*} - `, - { - badgeId, - userId, - currentUserId, - }, - ) + // Find used slot + const userBadges = ( + await context.database.query({ + query: ` + MATCH (rewardedBadge:Badge)-[rewarded:REWARDED]->(user:User {id: $userId}) + OPTIONAL MATCH (rewardedBadge)<-[selected:SELECTED]-(user) + RETURN collect(rewardedBadge {.*}) AS rewardedBadges, collect(toString(selected.slot)) AS usedSlots + `, + variables: { userId }, + }) + ).records.map((record) => { return { - relation: response.records.map((record) => record.get('relation'))[0], - user: response.records.map((record) => record.get('user'))[0], + rewardedBadges: record.get('rewardedBadges'), + usedSlots: record.get('usedSlots'), } }) - try { - const { relation, user } = await writeTxResultPromise - if (!relation) { - throw new Error( - 'Could not reward badge! Ensure the user and the badge exist and the badge is of the correct type.', - ) + + const { rewardedBadges, usedSlots } = userBadges[0] + + let slot + if ( + !rewardedBadges.find((item) => item.id === badgeId) && // badge was not rewarded yet + usedSlots.length < TROPHY_BADGES_SELECTED_MAX // there is free slots left + ) { + for (slot = 0; slot <= TROPHY_BADGES_SELECTED_MAX; slot++) { + if (!usedSlots.find((item) => parseInt(item) === slot)) { + break + } } - return user - } catch (error) { - throw new Error(error) - } finally { - session.close() } + + // reward badge and assign slot + const users = ( + await context.database.write({ + query: ` + MATCH (badge:Badge {id: $badgeId, type: 'trophy'}), (user:User {id: $userId}) + MERGE (badge)-[:REWARDED {by: $currentUserId}]->(user) + ${slot === undefined ? '' : 'MERGE (badge)<-[:SELECTED {slot: $slot}]-(user)'} + RETURN user {.*} + `, + variables: { badgeId, userId, currentUserId, slot }, + }) + ).records.map((record) => record.get('user')) + + if (users.length !== 1) { + throw new Error( + 'Could not reward badge! Ensure the user and the badge exist and the badge is of the correct type.', + ) + } + + return users[0] }, revokeBadge: async (_object, args, context, _resolveInfo) => { diff --git a/backend/src/graphql/resolvers/users.spec.ts b/backend/src/graphql/resolvers/users.spec.ts index f65f9eae2..2576c1f15 100644 --- a/backend/src/graphql/resolvers/users.spec.ts +++ b/backend/src/graphql/resolvers/users.spec.ts @@ -12,6 +12,7 @@ import databaseContext from '@context/database' import pubsubContext from '@context/pubsub' import Factory, { cleanDatabase } from '@db/factories' import User from '@db/models/User' +import { setTrophyBadgeSelected } from '@graphql/queries/setTrophyBadgeSelected' import createServer, { getContext } from '@src/server' const categoryIds = ['cat9'] @@ -77,22 +78,6 @@ const updateOnlineStatus = gql` } ` -const setTrophyBadgeSelected = gql` - mutation ($slot: Int!, $badgeId: ID) { - setTrophyBadgeSelected(slot: $slot, badgeId: $badgeId) { - badgeTrophiesCount - badgeTrophiesSelected { - id - isDefault - } - badgeTrophiesUnused { - id - } - badgeTrophiesUnusedCount - } - } -` - const resetTrophyBadgesSelected = gql` mutation { resetTrophyBadgesSelected {