Skip to content

Commit

Permalink
MCR-3767 CMS has unlock rate button (#2166)
Browse files Browse the repository at this point in the history
* Initial backend commit

* initial frontend commit

* Get button working and cleanup unused scss

* Add tests

* and add the feature flag

* Finish app-api tests, add assertionHelpers

* Fix padding by moving dl

* Fix word wrap in rate names with non zero space

* Revert "Fix word wrap in rate names with non zero space"

This reverts commit cf47ef1.

* Cleanup from code review
  • Loading branch information
haworku authored Jan 12, 2024
1 parent 3d2ee09 commit 3d3e82c
Show file tree
Hide file tree
Showing 18 changed files with 645 additions and 60 deletions.
2 changes: 2 additions & 0 deletions services/app-api/src/resolvers/configureResolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { rateResolver } from './rate/rateResolver'
import { fetchRateResolver } from './rate/fetchRate'
import { updateContract } from './contract/updateContract'
import { createAPIKeyResolver } from './APIKey'
import { unlockRate } from './rate/unlockRate'

export function configureResolvers(
store: Store,
Expand Down Expand Up @@ -88,6 +89,7 @@ export function configureResolvers(
emailParameterStore
),
createAPIKey: createAPIKeyResolver(jwt),
unlockRate: unlockRate(store),
},
User: {
// resolveType is required to differentiate Unions
Expand Down
122 changes: 122 additions & 0 deletions services/app-api/src/resolvers/rate/unlockRate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import {
constructTestPostgresServer,
createAndSubmitTestHealthPlanPackage,
} from '../../testHelpers/gqlHelpers'
import UNLOCK_RATE from '../../../../app-graphql/src/mutations/unlockRate.graphql'
import { testCMSUser } from '../../testHelpers/userHelpers'
import { latestFormData } from '../../testHelpers/healthPlanPackageHelpers'
import { expectToBeDefined } from '../../testHelpers/assertionHelpers'

describe(`unlockRate`, () => {
const cmsUser = testCMSUser()

it('changes rate status to UNLOCKED and creates a new draft revision with unlock info', async () => {
const stateServer = await constructTestPostgresServer()
const cmsServer = await constructTestPostgresServer({
context: {
user: cmsUser,
},
})

// Create a rate
const submission =
await createAndSubmitTestHealthPlanPackage(stateServer)
const initialSubmitFormData = latestFormData(submission)
const rateID = initialSubmitFormData.rateInfos[0].id
expect(rateID).toBeDefined()

// unlock rate
const unlockedReason = 'Super duper good reason.'
const unlockResult = await cmsServer.executeOperation({
query: UNLOCK_RATE,
variables: {
input: {
rateID,
unlockedReason,
},
},
})

expect(unlockResult.errors).toBeUndefined()

const updatedRate = unlockResult.data?.unlockRate.rate

console.info(updatedRate)
expect(updatedRate.status).toBe('UNLOCKED')
expect(updatedRate.draftRevision).toBeDefined()
expect(updatedRate.draftRevision.unlockInfo.updatedReason).toEqual(
unlockedReason
)
})

it('returns status error if rate is actively being edited in draft', async () => {
const stateServer = await constructTestPostgresServer()
const cmsServer = await constructTestPostgresServer({
context: {
user: cmsUser,
},
})

// Create a rate
const submission =
await createAndSubmitTestHealthPlanPackage(stateServer)
const initialSubmitFormData = latestFormData(submission)
const targetRateBeforeUnlock = initialSubmitFormData.rateInfos[0]
expectToBeDefined(targetRateBeforeUnlock.id)

// Unlock the rate once
const unlockResult1 = await cmsServer.executeOperation({
query: UNLOCK_RATE,
variables: {
input: {
rateID: targetRateBeforeUnlock.id,
unlockedReason: 'Super duper good reason.',
},
},
})

expect(unlockResult1.errors).toBeUndefined()

// Try to unlock the rate again
const unlockResult2 = await cmsServer.executeOperation({
query: UNLOCK_RATE,
variables: {
input: {
rateID: targetRateBeforeUnlock.id,
unlockedReason: 'Super duper good reason.',
},
},
})

expectToBeDefined(unlockResult2.errors)
expect(unlockResult2.errors[0].message).toBe(
'Attempted to unlock rate with wrong status'
)
})

it('returns unauthorized error for state user', async () => {
const stateServer = await constructTestPostgresServer()
// Create a rate
const submission =
await createAndSubmitTestHealthPlanPackage(stateServer)
const initialSubmitFormData = latestFormData(submission)
const targetRateBeforeUnlock = initialSubmitFormData.rateInfos[0]
expect(targetRateBeforeUnlock.id).toBeDefined()

// Unlock the rate
const unlockResult = await stateServer.executeOperation({
query: UNLOCK_RATE,
variables: {
input: {
rateID: targetRateBeforeUnlock.id,
unlockedReason: 'Super duper good reason.',
},
},
})

expectToBeDefined(unlockResult.errors)
expect(unlockResult.errors[0].message).toBe(
'user not authorized to unlock rate'
)
})
})
90 changes: 90 additions & 0 deletions services/app-api/src/resolvers/rate/unlockRate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { ForbiddenError, UserInputError } from 'apollo-server-lambda'
import type { RateType } from '../../domain-models'
import { isCMSUser } from '../../domain-models'
import type { MutationResolvers } from '../../gen/gqlServer'
import { logError, logSuccess } from '../../logger'
import { NotFoundError } from '../../postgres'
import type { Store } from '../../postgres'
import {
setErrorAttributesOnActiveSpan,
setResolverDetailsOnActiveSpan,
setSuccessAttributesOnActiveSpan,
} from '../attributeHelper'
import { GraphQLError } from 'graphql'

export function unlockRate(store: Store): MutationResolvers['unlockRate'] {
return async (_parent, { input }, context) => {
const { user, span } = context
const { unlockedReason, rateID } = input
setResolverDetailsOnActiveSpan('unlockRate', user, span)
span?.setAttribute('mcreview.rate_id', rateID)

// This resolver is only callable by CMS users
if (!isCMSUser(user)) {
logError('unlockRate', 'user not authorized to unlock rate')
setErrorAttributesOnActiveSpan(
'user not authorized to unlock rate',
span
)
throw new ForbiddenError('user not authorized to unlock rate')
}

const initialRateResult = await store.findRateWithHistory(rateID)

if (initialRateResult instanceof Error) {
if (initialRateResult instanceof NotFoundError) {
const errMessage = `A rate must exist to be unlocked: ${rateID}`
logError('unlockRate', errMessage)
setErrorAttributesOnActiveSpan(errMessage, span)
throw new UserInputError(errMessage, {
argumentName: 'rateID',
})
}

const errMessage = `Issue finding a rate. Message: ${initialRateResult.message}`
logError('unlockRate', errMessage)
setErrorAttributesOnActiveSpan(errMessage, span)
throw new GraphQLError(errMessage, {
extensions: {
code: 'INTERNAL_SERVER_ERROR',
cause: 'DB_ERROR',
},
})
}

const rate: RateType = initialRateResult

if (rate.draftRevision) {
const errMessage = `Attempted to unlock rate with wrong status`
logError('unlockRate', errMessage)
setErrorAttributesOnActiveSpan(errMessage, span)
throw new UserInputError(errMessage, {
argumentName: 'rateID',
cause: 'INVALID_PACKAGE_STATUS',
})
}

const unlockRateResult = await store.unlockRate({
rateID: rate.id,
unlockReason: unlockedReason,
unlockedByUserID: user.id,
})

if (unlockRateResult instanceof Error) {
const errMessage = `Failed to unlock rate revision with ID: ${rate.id}; ${unlockRateResult.message}`
logError('unlockRate', errMessage)
setErrorAttributesOnActiveSpan(errMessage, span)
throw new GraphQLError(errMessage, {
extensions: {
code: 'INTERNAL_SERVER_ERROR',
cause: 'DB_ERROR',
},
})
}

logSuccess('unlockRate')
setSuccessAttributesOnActiveSpan(span)

return { rate: unlockRateResult }
}
}
17 changes: 17 additions & 0 deletions services/app-api/src/testHelpers/assertionHelpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Functions that assists with type narrow in jest tests

// must - interrupts test flow to through an error
function must<T>(maybeErr: T | Error): T {
if (maybeErr instanceof Error) {
throw maybeErr
}
return maybeErr
}

// expectToBeDefined - properly type narrows the results of toBeDefined
// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/41179 would eventually address this but is not implemented
function expectToBeDefined<T>(arg: T): asserts arg is NonNullable<T> {
expect(arg).toBeDefined()
}

export { must, expectToBeDefined }
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type {
import type { StateCodeType } from '../../../../app-web/src/common-code/healthPlanFormDataType'
import type { ContractFormDataType } from '../../domain-models/contractAndRates'
import { findStatePrograms } from '../../postgres'
import { must } from '../errorHelpers'
import { must } from '../assertionHelpers'

const defaultContractData = () => ({
id: uuidv4(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type {
} from '../../postgres/contractAndRates/prismaSubmittedRateHelpers'
import type { StateCodeType } from '../../../../app-web/src/common-code/healthPlanFormDataType'
import { findStatePrograms } from '../../postgres'
import { must } from '../errorHelpers'
import { must } from '../assertionHelpers'

const defaultRateData = () => ({
id: '24fb2a5f-6d0d-4e26-9906-4de28927c882',
Expand Down
9 changes: 0 additions & 9 deletions services/app-api/src/testHelpers/errorHelpers.ts

This file was deleted.

2 changes: 1 addition & 1 deletion services/app-api/src/testHelpers/gqlHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ import type { LDService } from '../launchDarkly/launchDarkly'
import { insertUserToLocalAurora } from '../authn'
import { testStateUser } from './userHelpers'
import { findStatePrograms } from '../postgres'
import { must } from './errorHelpers'
import { must } from './assertionHelpers'
import { newJWTLib } from '../jwt'
import type { JWTLib } from '../jwt'

Expand Down
2 changes: 1 addition & 1 deletion services/app-api/src/testHelpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export {
assertAnErrorCode,
} from './gqlAssertions'

export { must } from './errorHelpers'
export { must } from './assertionHelpers'

export {
createInsertContractData,
Expand Down
2 changes: 1 addition & 1 deletion services/app-api/src/testHelpers/stateHelpers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { PrismaClient, State } from '@prisma/client'
import { must } from './errorHelpers'
import { must } from './assertionHelpers'

async function getStateRecord(
client: PrismaClient,
Expand Down
Loading

0 comments on commit 3d3e82c

Please sign in to comment.