Merge pull request #310 from specklesystems/izzy/activity_stream

feat(server): activity stream fixes, tests, and timeline
This commit is contained in:
izzy lyseggen
2021-06-24 16:19:00 +01:00
committed by GitHub
6 changed files with 158 additions and 25 deletions
@@ -1,7 +1,4 @@
const appRoot = require( 'app-root-path' )
const { validateServerRole, validateScopes } = require( `${appRoot}/modules/shared` )
const { ForbiddenError, UserInputError, ApolloError, withFilter } = require( 'apollo-server-express' )
const { getUserActivity, getStreamActivity, getResourceActivity, getUserTimeline, getActivityCountByResourceId, getActivityCountByStreamId, getActivityCountByUserId } = require( '../../services/index' )
const { getUserActivity, getStreamActivity, getResourceActivity, getUserTimeline, getActivityCountByResourceId, getActivityCountByStreamId, getActivityCountByUserId, getTimelineCount } = require( '../../services/index' )
module.exports = {
@@ -11,6 +8,13 @@ module.exports = {
let { items, cursor } = await getUserActivity( { userId: parent.id, actionType: args.actionType, after: args.after, before: args.before, limit: args.limit } )
let totalCount = await getActivityCountByUserId( { userId: parent.id } )
return { items, cursor, totalCount }
},
async timeline( parent, args, context, info ) {
let { items, cursor } = await getUserTimeline( { userId: parent.id, after: args.after, before: args.before, limit: args.limit } )
let totalCount = await getTimelineCount( { userId: parent.id } )
return { items, cursor, totalCount }
}
},
@@ -7,6 +7,9 @@ extend type User {
activity(actionType: String, after: DateTime, before: DateTime, limit: Int! = 25): ActivityCollection
@hasRole(role: "server:user")
@hasScope(scope: "users:read")
timeline(after: DateTime, before: DateTime, limit: Int! = 25): ActivityCollection
@hasRole(role: "server:user")
@hasScopes(scopes: ["users:read", "streams:read"])
}
extend type Stream {
@@ -3,7 +3,8 @@
const appRoot = require( 'app-root-path' )
const knex = require( `${appRoot}/db/knex` )
const StreamActivity = ( ) => knex( 'stream_activity' )
const StreamActivity = () => knex( 'stream_activity' )
const StreamAcl = ( ) => knex( 'stream_acl' )
module.exports = {
@@ -72,7 +73,7 @@ module.exports = {
}
if ( !before ) {
before = Date.now()
before = new Date()
}
let dbRawQuery = `
@@ -84,7 +85,7 @@ module.exports = {
LIMIT ?
`
let results = await knex.raw( dbRawQuery, [ userId, before, limit ] )
let results = ( await knex.raw( dbRawQuery, [ userId, before, limit ] ) ).rows
return { items: results, cursor: results.length > 0 ? results[ results.length - 1 ].time.toISOString() : null }
},
@@ -101,5 +102,13 @@ module.exports = {
async getActivityCountByUserId( { userId } ) {
let [ res ] = await StreamActivity().count().where( { userId } )
return parseInt( res.count )
},
async getTimelineCount( { userId } ) {
let [ res ] = await StreamAcl().count()
.innerJoin( 'stream_activity', { 'stream_acl.resourceId': 'stream_activity.streamId' } )
.where( { 'stream_acl.userId': userId } )
return parseInt( res.count )
}
}
@@ -1,24 +1,27 @@
/* istanbul ignore file */
const chai = require( 'chai' )
const chaiHttp = require( 'chai-http' )
const assert = require( 'assert' )
const appRoot = require( 'app-root-path' )
const { createUser } = require( '../../core/services/users' )
const { createPersonalAccessToken } = require( '../../core/services/tokens' )
const { createStream, grantPermissionsStream } = require( '../../core/services/streams' )
const { createObject } = require( '../../core/services/objects' )
const { init } = require( `${appRoot}/app` )
const { getUserActivity } = require( '../services' )
const { init, startHttp } = require( `${appRoot}/app` )
const knex = require( `${appRoot}/db/knex` )
const expect = chai.expect
chai.use( chaiHttp )
const serverAddress = `http://localhost:${process.env.PORT || 3000}`
describe( 'Activity @activity', () => {
let testServer
let userIz = {
name: 'Izzy Lyseggen',
email: 'izzyzzi@speckle.systems',
email: 'izzybizzi@speckle.systems',
password: 'sp0ckle sucks 9001'
}
@@ -40,6 +43,8 @@ describe( 'Activity @activity', () => {
isPublic: true
}
let branchPublic = { name: '🍁maple branch' }
let streamSecret = {
name: 'a secret stream for me',
description: 'for no one to see!',
@@ -61,7 +66,10 @@ describe( 'Activity @activity', () => {
await knex.migrate.rollback( )
await knex.migrate.latest()
await init()
let { app } = await init( )
let { server } = await startHttp( app )
testServer = server
// create users and tokens
userIz.id = await createUser( userIz )
@@ -72,37 +80,129 @@ describe( 'Activity @activity', () => {
userCr.token = `Bearer ${( await createPersonalAccessToken( userCr.id, 'cristi test token', [ 'streams:read', 'streams:write', 'users:read', 'users:email', 'tokens:write', 'tokens:read', 'profile:read', 'profile:email' ] ) )}`
userX.id = await createUser( userX )
userX.token = `Bearer ${( await createPersonalAccessToken( userX.id, 'no users test token', [ 'streams:read', 'streams:write' ] ) )}`
// create some activity
streamPublic.id = await createStream( { ...streamPublic, ownerId: userIz.id } )
await grantPermissionsStream( { streamId: streamPublic.id, userId: userCr.id, role: 'stream:contributor' } )
streamSecret.id = await createStream( { ...streamSecret, ownerId: userCr.id } )
testObj.id = await createObject( streamPublic.id, testObj )
testObj2.id = await createObject( streamSecret.id, testObj2 )
userX.token = `Bearer ${( await createPersonalAccessToken( userX.id, 'no users:read test token', [ 'streams:read', 'streams:write' ] ) )}`
} )
after( async ( ) => {
await knex.migrate.rollback( )
await knex.migrate.rollback()
testServer.close( )
} )
it( 'Should get a user\'s activity', async () => {
let { items: activity } = await getUserActivity( { userId: userCr.id } )
expect( activity.length ).to.equal( 1 )
expect( activity[ 0 ] ).to.have.property( 'actionType' )
expect( activity[ 0 ].actionType ).to.equal( 'user_create' )
} )
it( 'Should create activity', async () => {
// create stream (cr1)
const resStream1 = await sendRequest( userCr.token, { query: 'mutation createStream($myStream:StreamCreateInput!) { streamCreate(stream: $myStream) }', variables: { myStream: streamSecret } } )
expect( noErrors( resStream1 ) )
streamSecret.id = resStream1.body.data.streamCreate
// create commit (cr2)
testObj2.id = await createObject( streamSecret.id, testObj2 )
const resCommit1 = await sendRequest( userCr.token, { query: `mutation { commitCreate(commit: {streamId: "${streamSecret.id}", branchName: "main", objectId: "${testObj2.id}", message: "first commit"})}` } )
expect( noErrors( resCommit1 ) )
// create stream #2 (iz1)
const resStream2 = await sendRequest( userIz.token, { query: 'mutation createStream($myStream:StreamCreateInput!) { streamCreate(stream: $myStream) }', variables: { myStream: streamPublic } } )
expect( noErrors( resStream2 ) )
streamPublic.id = resStream2.body.data.streamCreate
// create branch (iz2)
const resBranch = await sendRequest( userIz.token, { query: `mutation { branchCreate(branch: { streamId: "${streamPublic.id}", name: "${branchPublic.name}" }) }` } )
expect( noErrors( resBranch ) )
branchPublic.id = resBranch.body.data.branchCreate
// create commit #2 (iz3)
testObj.id = await createObject( streamPublic.id, testObj )
const resCommit2 = await sendRequest( userIz.token, { query: `mutation { commitCreate(commit: { streamId: "${streamPublic.id}", branchName: "${branchPublic.name}", objectId: "${testObj.id}", message: "first commit" })}` } )
expect( noErrors( resCommit2 ) )
// add collaborator (iz4)
const resCollab = await sendRequest( userIz.token, { query: `mutation { streamGrantPermission( permissionParams: { streamId: "${streamPublic.id}", userId: "${userCr.id}", role: "stream:contributor" } ) }` } )
expect( noErrors( resCollab ) )
let { items: activityC } = await getUserActivity( { userId: userCr.id } )
expect( activityC.length ).to.equal( 3 )
expect( activityC[ 0 ].actionType ).to.equal( 'commit_create' )
let { items: activityI } = await getUserActivity( { userId: userIz.id } )
expect( activityI.length ).to.equal( 5 )
expect( activityI[ 0 ].actionType ).to.equal( 'stream_permissions_add' )
} )
it( 'Should get a user\'s timeline', async () => {
const res = await sendRequest( userIz.token, { query: `query {user(id:"${userCr.id}") { name timeline { totalCount items {streamId resourceType resourceId actionType userId message time}}} }` } )
expect( noErrors( res ) )
expect( res.body.data.user.timeline.items.length ).to.equal( 6 ) // sum of all actions in 'should create activity'
expect( res.body.data.user.timeline.totalCount ).to.equal( 6 )
} )
it( 'Should get another user\'s activity', async () => {
const res = await sendRequest( userIz.token, { query: `query {user(id:"${userCr.id}") { name activity { totalCount items {streamId resourceType resourceId actionType userId message time}}} }` } )
expect( noErrors( res ) )
expect( res.body.data.user.activity.items.length ).to.equal( 3 )
expect( res.body.data.user.activity.totalCount ).to.equal( 3 )
} )
it( 'Should get a stream\'s activity', async () => {
const res = await sendRequest( userCr.token, { query: `query { stream(id: "${streamPublic.id}") { activity { totalCount items {streamId resourceId actionType message} } } }` } )
expect( noErrors( res ) )
let activity = res.body.data.stream.activity
expect( activity.items.length ).to.equal( 4 )
expect( activity.totalCount ).to.equal( 4 )
expect( activity.items[ activity.totalCount - 1 ].actionType ).to.equal( 'stream_create' )
} )
it( 'Should get a branch\'s activity', async () => {
const res = await sendRequest( userCr.token, { query: `query { stream(id: "${streamPublic.id}") { branch(name: "${branchPublic.name}") { activity { totalCount items {streamId resourceId actionType message} } } } }` } )
expect( noErrors( res ) )
let activity = res.body.data.stream.branch.activity
expect( activity.items.length ).to.equal( 1 )
expect( activity.totalCount ).to.equal( 1 )
expect( activity.items[ 0 ].actionType ).to.equal( 'branch_create' )
} )
it( 'Should *not* get a stream\'s activity if you don\'t have access to it', async () => {
const res = await sendRequest( userIz.token, { query: `query {stream(id:"${streamSecret.id}") {name activity {items {streamId resourceType resourceId actionType userId message time}}} }` } )
expect( res.body.errors.length ).to.equal( 1 )
} )
it( 'Should *not* get a stream\'s activity if you are not a server user', async () => {
const res = await sendRequest( null, { query: `query {stream(id:"${streamPublic.id}") {name activity {items {streamId resourceType resourceId actionType userId message time}}} }` } )
expect( res.body.errors.length ).to.equal( 1 )
} )
it( 'Should *not* get a user\'s activity without the `users:read` scope', async () => {
const res = await sendRequest( userX.token, { query: `query {user(id:"${userCr.id}") { name activity {items {streamId resourceType resourceId actionType userId message time}}} }` } )
expect( res.body.errors.length ).to.equal( 1 )
} )
it( 'Should *not* get a user\'s timeline without the `users:read` scope', async () => {
const res = await sendRequest( userX.token, { query: `query {user(id:"${userCr.id}") { name timeline {items {streamId resourceType resourceId actionType userId message time}}} }` } )
expect( res.body.errors.length ).to.equal( 1 )
} )
} )
/**
* Sends a graphql request. Convenience wrapper.
* @param {string} auth the user's token
* @param {string} obj the query/mutation to send
* @return {Promise} the awaitable request
*/
function sendRequest( auth, obj, address = serverAddress ) {
return chai.request( address ).post( '/graphql' ).set( 'Authorization', auth ).send( obj )
}
/**
* Checks the response body for errors. To be used in expect assertions.
* Will throw an error if 'errors' exist.
* @param {*} res
*/
function noErrors( res ) {
if ( 'errors' in res.body ) throw new Error( `Failed GraphQL request: ${res.body.errors[ 0 ].message}` )
}
@@ -13,6 +13,22 @@ module.exports = {
const currentScopes = context.scopes
await validateScopes( currentScopes, requiredScope )
const data = await resolver.call( this, parent, args, context, info )
return data
}
}
},
hasScopes: class HasScopeDirective extends SchemaDirectiveVisitor {
visitFieldDefinition( field, details ) {
const { resolver = field.resolve || defaultFieldResolver, name } = field
const requiredScopes = this.args.scopes
field.resolve = async function ( parent, args, context, info ) {
const currentScopes = context.scopes
requiredScopes.forEach( async ( requiredScope ) => {
await validateScopes( currentScopes, requiredScope )
} )
const data = await resolver.call( this, parent, args, context, info )
return data
}
+3 -2
View File
@@ -14,12 +14,12 @@ exports.init = async ( app ) => {
let moduleDirs = [ './core', './auth', './apiexplorer', './emails', './pwdreset', './serverinvites', './previews' ] // TODO: add './invites'
// Stage 1: initialise all modules
for ( let dir of moduleDirs ){
for ( let dir of moduleDirs ) {
await require( dir ).init( app )
}
// Stage 2: finalize init all modules
for ( let dir of moduleDirs ){
for ( let dir of moduleDirs ) {
await require( dir ).finalize( app )
}
}
@@ -30,6 +30,7 @@ exports.graph = ( ) => {
let typeDefs = [ `
${scalarSchemas}
directive @hasScope(scope: String!) on FIELD_DEFINITION
directive @hasScopes(scopes: [String]!) on FIELD_DEFINITION
directive @hasRole(role: String!) on FIELD_DEFINITION
type Query {