forked from medplum/medplum
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* feat(AsyncJob): add `$cancel` operation for `AsyncJob`s * feat(AsyncJob): test that status is `accepted` before cancelling * test(AsyncJob): add tests for cancel operation * tweak(AsyncJob): No-op if already cancelled * fix(AsyncJob): don't attempt to patch unless `accepted` * fix(server): revert change to `sendOutcome` * fix(AsyncJob): remove unnecessary `try... catch` * test(AsyncJob): rm unnecessary test
- Loading branch information
1 parent
a50e27e
commit b0ed07f
Showing
10 changed files
with
203 additions
and
7 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -20,5 +20,6 @@ | |
}, | ||
"editor.codeActionsOnSave": { | ||
"source.fixAll.eslint": "never" | ||
} | ||
}, | ||
"cSpell.words": ["Medplum", "FHIR"] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
138 changes: 138 additions & 0 deletions
138
packages/server/src/fhir/operations/asyncjobcancel.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,138 @@ | ||
import { allOk, badRequest, ContentType, sleep } from '@medplum/core'; | ||
import { AsyncJob, OperationOutcome } from '@medplum/fhirtypes'; | ||
import express from 'express'; | ||
import request from 'supertest'; | ||
import { initApp, shutdownApp } from '../../app'; | ||
import { loadTestConfig } from '../../config'; | ||
import { initTestAuth } from '../../test.setup'; | ||
import { asyncJobCancelHandler } from './asyncjobcancel'; | ||
|
||
const app = express(); | ||
|
||
describe('AsyncJob/$cancel', () => { | ||
let accessToken: string; | ||
|
||
beforeAll(async () => { | ||
const config = await loadTestConfig(); | ||
await initApp(app, config); | ||
}); | ||
|
||
beforeEach(async () => { | ||
accessToken = await initTestAuth({ superAdmin: true }); | ||
expect(accessToken).toBeDefined(); | ||
}); | ||
|
||
afterAll(async () => { | ||
await shutdownApp(); | ||
}); | ||
|
||
test('Sets AsyncJob.status to `cancelled`', async () => { | ||
const res = await request(app) | ||
.post('/fhir/R4/AsyncJob') | ||
.set('Authorization', 'Bearer ' + accessToken) | ||
.set('Content-Type', ContentType.FHIR_JSON) | ||
.send({ | ||
resourceType: 'AsyncJob', | ||
status: 'accepted', | ||
requestTime: new Date().toISOString(), | ||
request: 'random-request', | ||
} satisfies AsyncJob); | ||
expect(res.status).toEqual(201); | ||
expect(res.body).toBeDefined(); | ||
|
||
const asyncJob = res.body as AsyncJob; | ||
|
||
const res2 = await request(app) | ||
.post(`/fhir/R4/AsyncJob/${asyncJob.id as string}/$cancel`) | ||
.set('Authorization', 'Bearer ' + accessToken); | ||
expect(res2.status).toEqual(200); | ||
expect(res2.body).toMatchObject<OperationOutcome>(allOk); | ||
|
||
const res3 = await request(app) | ||
.get(`/fhir/R4/AsyncJob/${asyncJob.id as string}`) | ||
.set('Authorization', 'Bearer ' + accessToken) | ||
.set('Content-Type', ContentType.FHIR_JSON); | ||
|
||
expect(res3.status).toEqual(200); | ||
expect(res3.body).toMatchObject<AsyncJob>({ | ||
id: asyncJob.id, | ||
resourceType: 'AsyncJob', | ||
status: 'cancelled', | ||
requestTime: asyncJob.requestTime, | ||
request: 'random-request', | ||
}); | ||
}); | ||
|
||
test('No-op when AsyncJob is already `cancelled`', async () => { | ||
const res = await request(app) | ||
.post('/fhir/R4/AsyncJob') | ||
.set('Authorization', 'Bearer ' + accessToken) | ||
.set('Content-Type', ContentType.FHIR_JSON) | ||
.send({ | ||
resourceType: 'AsyncJob', | ||
status: 'cancelled', | ||
requestTime: new Date().toISOString(), | ||
request: 'random-request', | ||
} satisfies AsyncJob); | ||
expect(res.status).toEqual(201); | ||
expect(res.body).toBeDefined(); | ||
|
||
const asyncJob = res.body as AsyncJob; | ||
|
||
const res2 = await request(app) | ||
.post(`/fhir/R4/AsyncJob/${asyncJob.id as string}/$cancel`) | ||
.set('Authorization', 'Bearer ' + accessToken); | ||
expect(res2.status).toEqual(200); | ||
expect(res2.body).toMatchObject<OperationOutcome>(allOk); | ||
|
||
const res3 = await request(app) | ||
.get(`/fhir/R4/AsyncJob/${asyncJob.id as string}`) | ||
.set('Authorization', 'Bearer ' + accessToken) | ||
.set('Content-Type', ContentType.FHIR_JSON); | ||
|
||
expect(res3.status).toEqual(200); | ||
expect(res3.body).toMatchObject<AsyncJob>({ | ||
id: asyncJob.id, | ||
resourceType: 'AsyncJob', | ||
status: 'cancelled', | ||
requestTime: asyncJob.requestTime, | ||
request: 'random-request', | ||
}); | ||
}); | ||
|
||
test.each(['completed', 'error'] as const)('Fails if AsyncJob.status is `%s`', async (status) => { | ||
const res = await request(app) | ||
.post('/fhir/R4/AsyncJob') | ||
.set('Authorization', 'Bearer ' + accessToken) | ||
.set('Content-Type', ContentType.FHIR_JSON) | ||
.send({ | ||
resourceType: 'AsyncJob', | ||
status, | ||
requestTime: new Date().toISOString(), | ||
request: 'random-request', | ||
} satisfies AsyncJob); | ||
expect(res.status).toEqual(201); | ||
expect(res.body).toBeDefined(); | ||
|
||
const asyncJob = res.body as AsyncJob; | ||
|
||
const res2 = await request(app) | ||
.post(`/fhir/R4/AsyncJob/${asyncJob.id as string}/$cancel`) | ||
.set('Authorization', 'Bearer ' + accessToken); | ||
expect(res2.status).toEqual(400); | ||
|
||
const outcome = res2.body as OperationOutcome; | ||
expect(outcome).toMatchObject<OperationOutcome>( | ||
badRequest(`AsyncJob cannot be cancelled if status is not 'accepted', job had status '${status}'`) | ||
); | ||
}); | ||
|
||
test('Fails if not executed on an instance (no ID given)', async () => { | ||
const next = jest.fn(); | ||
expect(() => asyncJobCancelHandler({ params: {} } as express.Request, {} as express.Response, next)).not.toThrow(); | ||
while (next.mock.calls.length === 0) { | ||
await sleep(100); | ||
} | ||
expect(next).toHaveBeenCalledWith(new Error('This operation can only be executed on an instance')); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
import { allOk, assert, badRequest } from '@medplum/core'; | ||
import { AsyncJob, OperationDefinition } from '@medplum/fhirtypes'; | ||
import { Request, Response } from 'express'; | ||
import { asyncWrap } from '../../async'; | ||
import { getAuthenticatedContext } from '../../context'; | ||
import { sendOutcome } from '../outcomes'; | ||
|
||
export const operation: OperationDefinition = { | ||
id: 'AsyncJob-cancel', | ||
resourceType: 'OperationDefinition', | ||
name: 'asyncjob-cancel', | ||
status: 'active', | ||
kind: 'operation', | ||
code: 'cancel', | ||
experimental: true, | ||
resource: ['AsyncJob'], | ||
system: false, | ||
type: false, | ||
instance: true, | ||
parameter: [{ use: 'out', name: 'return', type: 'OperationOutcome', min: 1, max: '1' }], | ||
}; | ||
|
||
/** | ||
* Handles HTTP requests for the AsyncJob $cancel operation. | ||
* Sets the status of the `AsyncJob` to `cancelled`. | ||
*/ | ||
export const asyncJobCancelHandler = asyncWrap(async (req: Request, res: Response) => { | ||
assert(req.params.id, 'This operation can only be executed on an instance'); | ||
// Update status of async job | ||
const { repo } = getAuthenticatedContext(); | ||
const job = await repo.readResource<AsyncJob>('AsyncJob', req.params.id); | ||
switch (job.status) { | ||
case 'accepted': | ||
await repo.patchResource('AsyncJob', req.params.id, [ | ||
{ op: 'test', path: '/status', value: 'accepted' }, | ||
{ op: 'add', path: '/status', value: 'cancelled' }, | ||
]); | ||
break; | ||
case 'cancelled': | ||
break; | ||
default: | ||
sendOutcome( | ||
res, | ||
badRequest(`AsyncJob cannot be cancelled if status is not 'accepted', job had status '${job.status}'`) | ||
); | ||
} | ||
sendOutcome(res, allOk); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters