From 62eefdb37b4211721d083d7360314551f87e8019 Mon Sep 17 00:00:00 2001 From: kao Date: Fri, 28 Apr 2023 13:49:12 -0600 Subject: [PATCH] Update history gql queries, add integration tests --- src/__tests__/history.ts | 138 ++++++++++++++++++ .../{integration.ts => organizations.ts} | 46 +----- src/db/ChangeLogType.ts | 4 + src/graphql/history/HistoryFieldResolvers.ts | 3 + src/graphql/history/HistoryQueries.ts | 12 +- src/graphql/schema/History.gql | 7 +- src/utils/inMemoryDB.ts | 6 + src/utils/testUtils.ts | 56 +++++++ 8 files changed, 231 insertions(+), 41 deletions(-) create mode 100644 src/__tests__/history.ts rename src/__tests__/{integration.ts => organizations.ts} (90%) create mode 100644 src/utils/testUtils.ts diff --git a/src/__tests__/history.ts b/src/__tests__/history.ts new file mode 100644 index 00000000..c80301af --- /dev/null +++ b/src/__tests__/history.ts @@ -0,0 +1,138 @@ +import { ApolloServer } from 'apollo-server' +import muuid from 'uuid-mongodb' +import { jest } from '@jest/globals' +import MutableAreaDataSource, { createInstance as createAreaInstance } from '../model/MutableAreaDataSource.js' +import MutableOrganizationDataSource, { createInstance as createOrgInstance } from '../model/MutableOrganizationDataSource.js' +import MutableClimbDataSource, { createInstance as createClimbInstance } from '../model/MutableClimbDataSource.js' +import { AreaType } from '../db/AreaTypes.js' +import { OrgType, OrganizationType } from '../db/OrganizationTypes.js' +import { muuidToString } from '../utils/helpers.js' +import { queryAPI, setUpServer } from '../utils/testUtils.js' + +jest.setTimeout(60000) + +describe('history API', () => { + let server: ApolloServer + let user: muuid.MUUID + let userUuid: string + let inMemoryDB + + // Mongoose models for mocking pre-existing state. + let areas: MutableAreaDataSource + let organizations: MutableOrganizationDataSource + let climbs: MutableClimbDataSource + + beforeAll(async () => { + ({ server, inMemoryDB } = await setUpServer()) + // Auth0 serializes uuids in "relaxed" mode, resulting in this hex string format + // "59f1d95a-627d-4b8c-91b9-389c7424cb54" instead of base64 "WfHZWmJ9S4yRuTicdCTLVA==". + user = muuid.mode('relaxed').v4() + userUuid = muuidToString(user) + }) + + beforeEach(async () => { + await inMemoryDB.clear() + areas = createAreaInstance() + organizations = createOrgInstance() + climbs = createClimbInstance() + }) + + afterAll(async () => { + await server.stop() + await inMemoryDB.close() + }) + + describe('queries', () => { + const FRAGMENT_CHANGE_HISTORY = ` + fragment ChangeHistoryFields on History { + id + createdAt + operation + editedBy + changes { + dbOp + changeId + updateDescription { + updatedFields + } + fullDocument { + ... on Area { + areaName + uuid + metadata { + leaf + areaId + } + } + ... on Climb { + id + name + uuid + } + ... on Organization { + orgId + } + } + } + } + ` + + const QUERY_RECENT_CHANGE_HISTORY = ` + ${FRAGMENT_CHANGE_HISTORY} + query ($filter: AllHistoryFilter) { + getChangeHistory(filter: $filter) { + ...ChangeHistoryFields + } + } + ` + + let usa: AreaType + let ca: AreaType + let alphaOrg: OrganizationType + let climbIds: string[] + + it('queries recent change history successfully', async () => { + // Make changes to be tracked. + usa = await areas.addCountry('usa') + ca = await areas.addArea(user, 'CA', usa.metadata.area_id) + const alphaFields = { + displayName: 'Alpha OpenBeta Club', + associatedAreaIds: [usa.metadata.area_id], + email: 'admin@alphaopenbeta.com' + } + alphaOrg = await organizations.addOrganization(user, OrgType.localClimbingOrganization, alphaFields) + climbIds = await climbs.addOrUpdateClimbs(user, ca.metadata.area_id, [{ name: 'Alpha Climb' }]) + + // Query for changes and ensure they are tracked. + const resp = await queryAPI({ + query: QUERY_RECENT_CHANGE_HISTORY, + variables: { filter: {} }, + userUuid + }) + expect(resp.statusCode).toBe(200) + const histories = resp.body.data.getChangeHistory + expect(histories.length).toBe(3) + + // Latest change first + // Note: addCountry is not captured by history. + const [climbChange, orgChange, areaChange] = histories + + expect(climbChange.operation).toBe('updateClimb') + expect(climbChange.editedBy).toBe(userUuid) + // Two changes: Insert the climb, update the parent area + // Ordering is non-deterministic. + expect(climbChange.changes.length).toBe(2) + const insertChange = climbChange.changes.filter(c => c.dbOp === 'insert')[0] + const updateChange = climbChange.changes.filter(c => c.dbOp === 'update')[0] + expect(insertChange.fullDocument.uuid).toBe(climbIds[0]) + expect(updateChange.fullDocument.uuid).toBe(muuidToString(ca.metadata.area_id)) + + expect(orgChange.operation).toBe('addOrganization') + expect(orgChange.editedBy).toBe(userUuid) + expect(orgChange.changes[0].fullDocument.orgId).toBe(muuidToString(alphaOrg.orgId)) + + expect(areaChange.operation).toBe('addArea') + expect(areaChange.editedBy).toBe(userUuid) + }) + }) +}) diff --git a/src/__tests__/integration.ts b/src/__tests__/organizations.ts similarity index 90% rename from src/__tests__/integration.ts rename to src/__tests__/organizations.ts index 5ccc7c6c..b4852e80 100644 --- a/src/__tests__/integration.ts +++ b/src/__tests__/organizations.ts @@ -1,51 +1,21 @@ import { ApolloServer } from 'apollo-server' import muuid from 'uuid-mongodb' import { jest } from '@jest/globals' -import { createServer } from '../server' -import { muuidToString, isMuuidHexStr } from '../utils/helpers' -import inMemoryDB from '../utils/inMemoryDB.js' -import request from 'supertest' -import jwt from 'jsonwebtoken' import MutableAreaDataSource, { createInstance as createAreaInstance } from '../model/MutableAreaDataSource.js' import MutableOrganizationDataSource, { createInstance as createOrgInstance } from '../model/MutableOrganizationDataSource.js' import { AreaType } from '../db/AreaTypes.js' import { OrgType, OrganizationType, OperationType, OrganizationEditableFieldsType } from '../db/OrganizationTypes.js' import { changelogDataSource } from '../model/ChangeLogDataSource.js' +import { queryAPI, setUpServer } from '../utils/testUtils.js' +import { muuidToString, isMuuidHexStr } from '../utils/helpers.js' jest.setTimeout(60000) -const PORT = 4000 -interface QueryAPIProps { - query: string - operationName: string - variables: any - userUuid: string - roles?: string[] -} -const queryAPI = async ({ query, operationName, variables, userUuid, roles = [] }: QueryAPIProps): Promise => { - // Avoid needing to pass in actual signed tokens. - const jwtSpy = jest.spyOn(jwt, 'verify') - jwtSpy.mockImplementation(() => { - return { - // Roles defined at https://manage.auth0.com/dashboard/us/dev-fmjy7n5n/roles - 'https://tacos.openbeta.io/roles': roles, - 'https://tacos.openbeta.io/uuid': userUuid - } - }) - - const queryObj = { query, operationName, variables } - const response = await request(`http://localhost:${PORT}`) - .post('/') - .send(queryObj) - .set('Authorization', 'Bearer placeholder-jwt-see-SpyOn') - - return response -} - -describe('graphql server', () => { +describe('organizations API', () => { let server: ApolloServer let user: muuid.MUUID let userUuid: string + let inMemoryDB // Mongoose models for mocking pre-existing state. let areas: MutableAreaDataSource @@ -55,9 +25,7 @@ describe('graphql server', () => { let wa: AreaType beforeAll(async () => { - server = await createServer() - await inMemoryDB.connect() - await server.listen({ port: PORT }) + ({ server, inMemoryDB } = await setUpServer()) // Auth0 serializes uuids in "relaxed" mode, resulting in this hex string format // "59f1d95a-627d-4b8c-91b9-389c7424cb54" instead of base64 "WfHZWmJ9S4yRuTicdCTLVA==". user = muuid.mode('relaxed').v4() @@ -78,7 +46,7 @@ describe('graphql server', () => { await inMemoryDB.close() }) - describe('mutation API', () => { + describe('mutations', () => { const createQuery = ` mutation addOrganization($input: AddOrganizationInput!) { organization: addOrganization(input: $input) { @@ -198,7 +166,7 @@ describe('graphql server', () => { }) }) - describe('query API', () => { + describe('queries', () => { const organizationQuery = ` query organization($input: MUUID) { organization(muuid: $input) { diff --git a/src/db/ChangeLogType.ts b/src/db/ChangeLogType.ts index bcf46341..49a495b1 100644 --- a/src/db/ChangeLogType.ts +++ b/src/db/ChangeLogType.ts @@ -68,3 +68,7 @@ export interface GetHistoryInputFilterType { export interface GetAreaHistoryInputFilterType { areaId: string } + +export interface GetOrganizationHistoryInputFilterType { + orgId: MUUID +} diff --git a/src/graphql/history/HistoryFieldResolvers.ts b/src/graphql/history/HistoryFieldResolvers.ts index bb28ff56..a21949d5 100644 --- a/src/graphql/history/HistoryFieldResolvers.ts +++ b/src/graphql/history/HistoryFieldResolvers.ts @@ -30,6 +30,9 @@ const resolvers = { if (node.kind === 'climbs') { return 'Climb' } + if (node.kind === 'organizations') { + return 'Organization' + } return null } } diff --git a/src/graphql/history/HistoryQueries.ts b/src/graphql/history/HistoryQueries.ts index acc105ba..4af61fa4 100644 --- a/src/graphql/history/HistoryQueries.ts +++ b/src/graphql/history/HistoryQueries.ts @@ -1,6 +1,10 @@ import muid from 'uuid-mongodb' -import { GetHistoryInputFilterType, GetAreaHistoryInputFilterType } from '../../db/ChangeLogType.js' +import { + GetHistoryInputFilterType, + GetAreaHistoryInputFilterType, + GetOrganizationHistoryInputFilterType +} from '../../db/ChangeLogType.js' import { DataSourcesType } from '../../types.js' const HistoryQueries = { @@ -18,6 +22,12 @@ const HistoryQueries = { const { areaId }: GetAreaHistoryInputFilterType = filter ?? {} const id = muid.from(areaId) return await history.getAreaChangeSets(id) + }, + + getOrganizationHistory: async (_, { filter }, { dataSources }): Promise => { + const { history }: DataSourcesType = dataSources + const { orgId }: GetOrganizationHistoryInputFilterType = filter ?? {} + return await history.getOrganizationChangeSets(orgId) } } diff --git a/src/graphql/schema/History.gql b/src/graphql/schema/History.gql index 1dd29b54..2b32b535 100644 --- a/src/graphql/schema/History.gql +++ b/src/graphql/schema/History.gql @@ -9,6 +9,10 @@ input AreaHistoryFilter { areaId: ID } +input OrganizationHistoryFilter { + orgId: MUUID +} + type UpdateDescription { updatedFields: [String] } @@ -20,7 +24,7 @@ type Change { updateDescription: UpdateDescription } -union Document = Area | Climb +union Document = Area | Climb | Organization type History { id: ID! @@ -33,4 +37,5 @@ type History { type Query { getChangeHistory(filter: AllHistoryFilter): [History] getAreaHistory(filter: AreaHistoryFilter): [History] + getOrganizationHistory(filter: OrganizationHistoryFilter): [History] } diff --git a/src/utils/inMemoryDB.ts b/src/utils/inMemoryDB.ts index 30fea703..8abd4c9f 100644 --- a/src/utils/inMemoryDB.ts +++ b/src/utils/inMemoryDB.ts @@ -51,4 +51,10 @@ const clear = async (): Promise => { } } +export interface InMemoryDB { + connect: () => Promise + close: () => Promise + clear: () => Promise +} + export default { connect, close, clear } diff --git a/src/utils/testUtils.ts b/src/utils/testUtils.ts new file mode 100644 index 00000000..7013f5a0 --- /dev/null +++ b/src/utils/testUtils.ts @@ -0,0 +1,56 @@ +import jwt from 'jsonwebtoken' +import { jest } from '@jest/globals' +import request from 'supertest' +import inMemoryDB from './inMemoryDB.js' +import type { InMemoryDB } from './inMemoryDB.js' +import { createServer } from '../server.js' +import { ApolloServer } from 'apollo-server' + +const PORT = 4000 + +interface QueryAPIProps { + query: string + operationName?: string + variables: any + userUuid: string + roles?: string[] + port?: number +} + +/* + * Helper function for querying the locally-served API. It mocks JWT verification + * so we can pretend to have an role we want when calling the API. + */ +export const queryAPI = async ({ query, operationName, variables, userUuid, roles = [], port = PORT }: QueryAPIProps): Promise => { + // Avoid needing to pass in actual signed tokens. + const jwtSpy = jest.spyOn(jwt, 'verify') + jwtSpy.mockImplementation(() => { + return { + // Roles defined at https://manage.auth0.com/dashboard/us/dev-fmjy7n5n/roles + 'https://tacos.openbeta.io/roles': roles, + 'https://tacos.openbeta.io/uuid': userUuid + } + }) + + const queryObj = { query, operationName, variables } + const response = await request(`http://localhost:${port}`) + .post('/') + .send(queryObj) + .set('Authorization', 'Bearer placeholder-jwt-see-SpyOn') + + return response +} + +interface SetUpServerReturnType { + server: ApolloServer + inMemoryDB: InMemoryDB +} +/* + * Starts Apollo server and has Mongo inMemory replset connect to it. +*/ +export const setUpServer = async (port = PORT): Promise => { + const server = await createServer() + await inMemoryDB.connect() + await server.listen({ port }) + return { server, inMemoryDB } +}