basic specs for inviteCodes

This commit is contained in:
Moriz Wahl 2021-01-11 21:25:02 +01:00
parent a1967815bf
commit dd6cafed4c
8 changed files with 144 additions and 38 deletions

View File

@ -149,7 +149,7 @@ export default shield(
pinPost: isAdmin,
unpinPost: isAdmin,
UpdateDonations: isAdmin,
CreateInviteCode: isAuthenticated,
GenerateInviteCode: isAuthenticated,
},
User: {
email: or(isMyOwn, isAdmin),

View File

@ -100,15 +100,15 @@ export default {
target: 'User',
direction: 'in',
},
createdInvite: {
inviteCodes: {
type: 'relationship',
relationship: 'CREATED',
relationship: 'GENERATED',
target: 'InviteCode',
direction: 'out',
},
usedInvite: {
redeemedInviteCode: {
type: 'relationship',
relationship: 'USED',
relationship: 'REDEEMED',
target: 'InviteCode',
direction: 'out',
},

View File

@ -1,5 +1,5 @@
export default function generateInviteCode() {
return Array.from({length: 6}, (n = Math.floor(Math.random() * 36)) => {
return Array.from({ length: 6 }, (n = Math.floor(Math.random() * 36)) => {
return String.fromCharCode(n > 9 ? n + 55 : n + 48)
}).join('')
}).join('')
}

View File

@ -1,65 +1,60 @@
import { neo4jgraphql } from 'neo4j-graphql-js'
import generateInvieCode from './helpers/generateInviteCode'
import Resolver from './helpers/Resolver'
const uniqueInviteCode = async (session, code) => {
return session.readTransaction(async (txc) => {
const result = await txc.run(
`MATCH (ic:InviteCode { id: $code }) RETURN count(ic) AS count`,
{ code },
)
return parseInt(String(result.records[0].get('count'))) === 0
const result = await txc.run(`MATCH (ic:InviteCode { id: $code }) RETURN count(ic) AS count`, {
code,
})
return parseInt(String(result.records[0].get('count'))) === 0
})
}
export default {
Mutation: {
CreateInviteCode: async (_parent, args, context, _resolveInfo) => {
GenerateInviteCode: async (_parent, args, context, _resolveInfo) => {
const {
user: { id: userId },
} = context
const session = context.driver.session()
let code = generateInvieCode()
let response
while(!await uniqueInviteCode(session, code)) {
while (!(await uniqueInviteCode(session, code))) {
code = generateInvieCode()
}
const writeTxResultPromise = session.writeTransaction(async (txc) => {
const result = await txc.run(
`MATCH (user:User {id: $userId})
MERGE (user)-[:CREATED]->(ic:InviteCode {
code: $code,
createdAt: toString(datetime()),
uses: $uses,
maxUses: $maxUses,
active: true
}) RETURN ic AS inviteCode`,
MERGE (user)-[:GENERATED]->(ic:InviteCode { code: $code })
ON CREATE SET
ic.createdAt = toString(datetime()),
ic.expiresAt = $expiresAt
RETURN ic AS inviteCode`,
{
userId,
code,
maxUses: args.maxUses,
uses: 0,
expiresAt: args.expiresAt,
},
)
return result.records.map((record) => record.get('inviteCode').properties)
})
try {
const txResult = await writeTxResultPromise
console.log(txResult)
response = txResult[0]
} finally {
session.close()
}
return response
}
},
},
InviteCode: {
...Resolver('InviteCode', {
undefinedToNull: ['expiresAt'],
hasOne: {
createdBy: '<-[:CREATED]-(related:User)',
generatedBy: '<-[:GENERATED]-(related:User)',
},
hasMany: {
usedBy: '<-[:USED]-(related:User)',
redeemedBy: '<-[:REDEEMED]-(related:User)',
},
}),
},

View File

@ -0,0 +1,113 @@
import Factory, { cleanDatabase } from '../../db/factories'
import { getDriver } from '../../db/neo4j'
import { gql } from '../../helpers/jest'
import createServer from '../../server'
import { createTestClient } from 'apollo-server-testing'
let user
// let query
let mutate
const driver = getDriver()
const generateInviteCodeMutation = gql`
mutation($expiresAt: String = null) {
GenerateInviteCode(expiresAt: $expiresAt) {
code
createdAt
expiresAt
}
}
`
beforeAll(async () => {
await cleanDatabase()
const { server } = createServer({
context: () => {
return {
driver,
user,
}
},
})
// query = createTestClient(server).query
mutate = createTestClient(server).mutate
})
afterAll(async () => {
await cleanDatabase()
})
describe('inviteCodes', () => {
describe('generate invite code', () => {
describe('as unauthenticated user', () => {
it('returns permission denied error', async () => {
await expect(mutate({ mutation: generateInviteCodeMutation })).resolves.toEqual(
expect.objectContaining({
errors: expect.arrayContaining([
expect.objectContaining({
extensions: { code: 'INTERNAL_SERVER_ERROR' },
}),
]),
data: {
GenerateInviteCode: null,
},
}),
)
})
})
describe('as authenticated user', () => {
beforeAll(async () => {
const authenticatedUser = await Factory.build(
'user',
{
role: 'user',
},
{
email: 'user@example.org',
password: '1234',
},
)
user = await authenticatedUser.toJson()
})
it('generates an invite code without expiresAt', async () => {
await expect(mutate({ mutation: generateInviteCodeMutation })).resolves.toEqual(
expect.objectContaining({
errors: undefined,
data: {
GenerateInviteCode: {
code: expect.stringMatching(/^[0-9A-Z]{6,6}$/),
expiresAt: null,
createdAt: expect.any(String),
},
},
}),
)
})
it('generates an invite code with expiresAt', async () => {
const nextWeek = new Date()
nextWeek.setDate(nextWeek.getDate() + 7)
await expect(
mutate({
mutation: generateInviteCodeMutation,
variables: { expiresAt: nextWeek.toISOString() },
}),
).resolves.toEqual(
expect.objectContaining({
errors: undefined,
data: {
GenerateInviteCode: {
code: expect.stringMatching(/^[0-9A-Z]{6,6}$/),
expiresAt: nextWeek.toISOString(),
createdAt: expect.any(String),
},
},
}),
)
})
})
})
})

View File

@ -293,7 +293,7 @@ export default {
avatar: '-[:AVATAR_IMAGE]->(related:Image)',
invitedBy: '<-[:INVITED]-(related:User)',
location: '-[:IS_IN]->(related:Location)',
usedInviteCode: '-[:USED]->(related:InviteCode)',
redeemedInviteCode: '-[:REDEEMED]->(related:InviteCode)',
},
hasMany: {
followedBy: '<-[:FOLLOWS]-(related:User)',
@ -305,7 +305,7 @@ export default {
shouted: '-[:SHOUTED]->(related:Post)',
categories: '-[:CATEGORIZED]->(related:Category)',
badges: '<-[:REWARDED]-(related:Badge)',
inviteCodes: '-[:CREATED]->(related:InviteCode)',
inviteCodes: '-[:GENERATED]->(related:InviteCode)',
},
}),
},

View File

@ -1,14 +1,12 @@
type InviteCode {
code: ID!
createdAt: String
uses: Int!
maxUses: Int!
createdBy: User @relation(name: "CREATED", direction: "IN")
usedBy: [User] @relation(name: "USED", direction: "IN")
active: Boolean!
generatedBy: User @relation(name: "GENERATED", direction: "IN")
redeemedBy: [User] @relation(name: "REDEEMED", direction: "IN")
expiresAt: String
}
type Mutation {
CreateInviteCode(maxUses: Int = 1): InviteCode
GenerateInviteCode(expiresAt: String = null): InviteCode
}

View File

@ -56,8 +56,8 @@ type User {
followedBy: [User]! @relation(name: "FOLLOWS", direction: "IN")
followedByCount: Int! @cypher(statement: "MATCH (this)<-[:FOLLOWS]-(r:User) RETURN COUNT(DISTINCT r)")
inviteCodes: [InviteCode] @relation(name: "CREATED", direction: "OUT")
usedInviteCode: InviteCode @relation(name: "USED", direction: "OUT")
inviteCodes: [InviteCode] @relation(name: "GENERATED", direction: "OUT")
redeemedInviteCode: InviteCode @relation(name: "REDEEMED", direction: "OUT")
# Is the currently logged in user following that user?
followedByCurrentUser: Boolean! @cypher(