diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6060b9e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + - run: npm ci + - run: npm run lint + - run: npm test diff --git a/lib/api.js b/lib/api.js index e298d92..aae8c1b 100644 --- a/lib/api.js +++ b/lib/api.js @@ -9,26 +9,23 @@ export default class REDCapAPI { this.#token = token } - async #call (options, headers = { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' }) { - options.format = 'json' - options.token = this.#token + async #post (options, headers) { + // build a fresh object: the caller’s options must not gain our token const response = await fetch(this.#endpoint, { method: 'POST', headers, - body: makeParams(options) + body: makeParams({ ...options, token: this.#token }) }) if (!response.ok) throw new Error(`${response.status} ${response.statusText}`) - return await response.json() + return response + } + + async #call (options, headers = { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' }) { + return await (await this.#post({ ...options, format: 'json' }, headers)).json() } async version () { - const response = await fetch(this.#endpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: makeParams({ content: 'version', token: this.#token }) - }) - if (!response.ok) throw new Error(`${response.status} ${response.statusText}`) - return await response.text() + return await (await this.#post({ content: 'version' }, { 'Content-Type': 'application/x-www-form-urlencoded' })).text() } async project () { @@ -44,73 +41,63 @@ export default class REDCapAPI { } async metadata (options = {}) { - options.content = 'metadata' - return await this.#call(options) + return await this.#call({ ...options, content: 'metadata' }) } async records (options = {}) { - options.content = 'record' - if (!('type' in options)) options.type = 'flat' - return await this.#call(options) + return await this.#call({ type: 'flat', ...options, content: 'record' }) } async events (options = {}) { - options.content = 'event' - return await this.#call(options) + return await this.#call({ ...options, content: 'event' }) } async arms (options = {}) { - options.content = 'arm' - return await this.#call(options) + return await this.#call({ ...options, content: 'arm' }) } async fields (options = {}) { - options.content = 'exportFieldNames' - return await this.#call(options) + return await this.#call({ ...options, content: 'exportFieldNames' }) } async mapping (options = {}) { - options.content = 'formEventMapping' - return await this.#call(options) + return await this.#call({ ...options, content: 'formEventMapping' }) } async write (data, options = {}) { - options.content = 'record' - if (!('type' in options)) options.type = 'flat' - options.data = JSON.stringify(Array.isArray(data) ? data : [data]) - return await this.#call(options) + return await this.#call({ + type: 'flat', + ...options, + content: 'record', + data: JSON.stringify(Array.isArray(data) ? data : [data]) + }) } async file (options = {}) { if (!('record' in options)) throw new TypeError('options must specify “record”') if (!('field' in options)) throw new TypeError('options must specify “field”') - options.token = this.#token - options.content = 'file' - options.action = 'export' - const response = await fetch(this.#endpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: makeParams(options) - }) - if (!response.ok) throw new Error(`${response.status} ${response.statusText}`) - const re = /^(?[^;]+); name="(?.+)"$/gi - const match = re.exec(response.headers.get('content-type')) - const bytes = await response.blob() - return new File([bytes], match.groups.name, { type: match.groups.type }) + const response = await this.#post( + { ...options, content: 'file', action: 'export' }, + { 'Content-Type': 'application/x-www-form-urlencoded' } + ) + const [type, ...params] = (response.headers.get('content-type') ?? '').split(';').map(s => s.trim()) + const filename = params.find(param => /^name\s*=/i.test(param)) + return new File( + [await response.blob()], + filename ? unquote(filename.slice(1 + filename.indexOf('=')).trim()) : `${options.record}-${options.field}`, + { type } + ) } async upload (file, options = {}) { if (!(file instanceof File)) throw new TypeError('file must be an instance of File') if (!('record' in options)) throw new TypeError('options must specify “record”') if (!('field' in options)) throw new TypeError('options must specify “field”') - options.token = this.#token - options.content = 'file' - options.action = 'import' - options.file = file const data = new FormData() - for (const [name, value] of Object.entries(options)) { + for (const [name, value] of Object.entries({ ...options, token: this.#token, content: 'file', action: 'import' })) { data.append(name, value) } + data.append('file', file) const response = await fetch(this.#endpoint, { method: 'POST', headers: { Accept: 'application/json' }, @@ -120,6 +107,10 @@ export default class REDCapAPI { } } +function unquote (s) { + return s.startsWith('"') && s.endsWith('"') ? s.slice(1, -1) : s +} + function makeParams (options) { const params = new URLSearchParams() for (const [option, value] of Object.entries(options)) { diff --git a/package.json b/package.json index d36a6fd..a0430ca 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,10 @@ "lib/instrument.js", "lib/project.js" ], + "scripts": { + "lint": "eslint .", + "test": "node --test" + }, "repository": { "type": "git", "url": "git+https://github.com/robireton/redcap.git" diff --git a/test/api.test.js b/test/api.test.js new file mode 100644 index 0000000..7e47c30 --- /dev/null +++ b/test/api.test.js @@ -0,0 +1,220 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import http from 'node:http' +import { once } from 'node:events' + +import REDCapAPI from '../lib/api.js' + +// Stand up a real server on an ephemeral port so the tests exercise fetch, +// request bodies and response headers for real — no mocking library needed. +async function withServer (handler, run) { + const requests = [] + const server = http.createServer(async (req, res) => { + const chunks = [] + for await (const chunk of req) chunks.push(chunk) + const body = Buffer.concat(chunks) + const request = { method: req.method, headers: req.headers, body } + requests.push(request) + handler(request, res) + }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + try { + return await run(`http://127.0.0.1:${server.address().port}/api/`, requests) + } finally { + server.close() + await once(server, 'close') + } +} + +// echoes the received form-encoded parameters back as JSON +function echo (request, res) { + const params = new URLSearchParams(request.body.toString('utf8')) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(Object.fromEntries(params))) +} + +const sent = requests => Object.fromEntries(new URLSearchParams(requests.at(-1).body.toString('utf8'))) + +test('does not mutate the caller’s options or leak the token into them', async () => { + await withServer(echo, async (endpoint, requests) => { + const api = new REDCapAPI(endpoint, 'SECRET') + const options = { fields: ['a', 'b'] } + await api.metadata(options) + + assert.deepEqual(options, { fields: ['a', 'b'] }, 'options should be untouched') + assert.ok(!('token' in options), 'the token must not be written into the caller’s object') + assert.ok(!('format' in options)) + assert.ok(!('content' in options)) + assert.equal(sent(requests).token, 'SECRET', 'the token should still be sent') + }) +}) + +test('an options object is reusable across calls without carrying stale keys', async () => { + await withServer(echo, async (endpoint, requests) => { + const api = new REDCapAPI(endpoint, 'SECRET') + const options = {} + await api.records(options) + await api.metadata(options) + + assert.deepEqual(options, {}) + assert.equal(sent(requests).content, 'metadata', 'the second call must not inherit content=record') + assert.ok(!('type' in sent(requests)), 'the second call must not inherit type=flat') + }) +}) + +test('records defaults to flat and lets the caller override', async () => { + await withServer(echo, async (endpoint, requests) => { + const api = new REDCapAPI(endpoint, 'SECRET') + + await api.records() + assert.equal(sent(requests).type, 'flat') + assert.equal(sent(requests).content, 'record') + + await api.records({ type: 'eav' }) + assert.equal(sent(requests).type, 'eav') + }) +}) + +test('array options serialize with indexed names', async () => { + await withServer(echo, async (endpoint, requests) => { + const api = new REDCapAPI(endpoint, 'SECRET') + await api.metadata({ fields: ['age', 'sex'], forms: ['demographics'] }) + + const params = sent(requests) + assert.equal(params['fields[0]'], 'age') + assert.equal(params['fields[1]'], 'sex') + assert.equal(params['forms[0]'], 'demographics') + }) +}) + +test('write wraps a single record and serializes as JSON', async () => { + await withServer(echo, async (endpoint, requests) => { + const api = new REDCapAPI(endpoint, 'SECRET') + + await api.write({ record_id: '1' }) + assert.deepEqual(JSON.parse(sent(requests).data), [{ record_id: '1' }]) + assert.equal(sent(requests).type, 'flat') + + await api.write([{ record_id: '1' }, { record_id: '2' }]) + assert.equal(JSON.parse(sent(requests).data).length, 2) + }) +}) + +test('every content type requests JSON except version', async () => { + await withServer((request, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }) + res.end('14.0.1') + }, async (endpoint, requests) => { + const api = new REDCapAPI(endpoint, 'SECRET') + assert.equal(await api.version(), '14.0.1') + + const params = sent(requests) + assert.equal(params.content, 'version') + assert.equal(params.token, 'SECRET') + assert.ok(!('format' in params), 'version is plain text, not JSON') + }) +}) + +test('a non-ok response throws with the status', async () => { + await withServer((request, res) => { + res.writeHead(403, 'Forbidden') + res.end('nope') + }, async endpoint => { + const api = new REDCapAPI(endpoint, 'SECRET') + await assert.rejects(() => api.project(), /403/) + }) +}) + +test('file() reads the filename from a bare content type', async () => { + await withServer((request, res) => { + res.writeHead(200, { 'Content-Type': 'application/pdf; name="consent.pdf"' }) + res.end('%PDF-1.4') + }, async endpoint => { + const api = new REDCapAPI(endpoint, 'SECRET') + const file = await api.file({ record: '1', field: 'consent_form' }) + assert.equal(file.name, 'consent.pdf') + assert.equal(file.type, 'application/pdf') + assert.equal(await file.text(), '%PDF-1.4') + }) +}) + +test('file() reads the filename when a charset comes first', async () => { + await withServer((request, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain; charset=UTF-8; name="notes.txt"' }) + res.end('hello') + }, async endpoint => { + const api = new REDCapAPI(endpoint, 'SECRET') + const file = await api.file({ record: '1', field: 'notes' }) + assert.equal(file.name, 'notes.txt') + assert.equal(file.type, 'text/plain') + }) +}) + +test('file() falls back to a derived name when none is offered', async () => { + await withServer((request, res) => { + res.writeHead(200, { 'Content-Type': 'application/octet-stream' }) + res.end('data') + }, async endpoint => { + const api = new REDCapAPI(endpoint, 'SECRET') + const file = await api.file({ record: '7', field: 'scan' }) + assert.equal(file.name, '7-scan') + assert.equal(file.type, 'application/octet-stream') + }) +}) + +test('file() sends the export action and does not mutate options', async () => { + await withServer((request, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain; name="x.txt"' }) + res.end('x') + }, async (endpoint, requests) => { + const api = new REDCapAPI(endpoint, 'SECRET') + const options = { record: '1', field: 'notes' } + await api.file(options) + + assert.deepEqual(options, { record: '1', field: 'notes' }) + const params = sent(requests) + assert.equal(params.content, 'file') + assert.equal(params.action, 'export') + assert.equal(params.token, 'SECRET') + }) +}) + +test('file() requires record and field', async () => { + const api = new REDCapAPI('https://example.invalid/api/', 'SECRET') + await assert.rejects(() => api.file({ field: 'notes' }), TypeError) + await assert.rejects(() => api.file({ record: '1' }), TypeError) +}) + +test('upload() sends multipart form data and does not mutate options', async () => { + await withServer((request, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end('{}') + }, async (endpoint, requests) => { + const api = new REDCapAPI(endpoint, 'SECRET') + const options = { record: '1', field: 'notes' } + await api.upload(new File(['hello'], 'notes.txt', { type: 'text/plain' }), options) + + assert.deepEqual(options, { record: '1', field: 'notes' }) + const request = requests.at(-1) + assert.match(request.headers['content-type'], /^multipart\/form-data/) + const body = request.body.toString('utf8') + assert.match(body, /name="token"[\s\S]*SECRET/) + assert.match(body, /name="action"[\s\S]*import/) + assert.match(body, /name="file"; filename="notes.txt"/) + }) +}) + +test('upload() validates its arguments', async () => { + const api = new REDCapAPI('https://example.invalid/api/', 'SECRET') + await assert.rejects(() => api.upload('not a file', { record: '1', field: 'f' }), TypeError) + await assert.rejects(() => api.upload(new File([''], 'x'), { field: 'f' }), TypeError) + await assert.rejects(() => api.upload(new File([''], 'x'), { record: '1' }), TypeError) +}) + +test('the constructor validates endpoint and token', () => { + assert.throws(() => new REDCapAPI(42, 'SECRET'), TypeError) + assert.throws(() => new REDCapAPI('https://example.invalid/', ''), TypeError) + assert.throws(() => new REDCapAPI('https://example.invalid/', null), TypeError) + assert.ok(new REDCapAPI(new URL('https://example.invalid/api/'), 'SECRET')) +}) diff --git a/test/exports.test.js b/test/exports.test.js new file mode 100644 index 0000000..61dede7 --- /dev/null +++ b/test/exports.test.js @@ -0,0 +1,44 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import path from 'node:path' + +import * as index from '../index.js' + +const root = path.resolve(import.meta.dirname, '..') +const pkg = JSON.parse(await readFile(path.resolve(root, 'package.json'), { encoding: 'utf8' })) + +const names = [ + 'REDCapAPI', + 'REDCapDatetime', + 'REDCapField', + 'REDCapInstrument', + 'REDCapProject', + 'REDCapProjectInformation' +] + +test('index.js exports every class and nothing else', () => { + for (const name of names) { + assert.equal(typeof index[name], 'function', `${name} should be exported from index.js`) + } + assert.deepEqual(Object.keys(index).sort(), names) +}) + +test('every subpath export resolves to the same class index.js exports', async () => { + for (const [subpath, target] of Object.entries(pkg.exports)) { + if (subpath === '.') continue + const module = await import(path.resolve(root, target)) + assert.equal(typeof module.default, 'function', `${subpath} should default-export a class`) + assert.equal(module.default, index[module.default.name], `${subpath} should be the class index.js exports as ${module.default.name}`) + } +}) + +test('every class has a subpath export', () => { + assert.equal(Object.keys(pkg.exports).length, names.length + 1, 'expected one subpath per class, plus "."') +}) + +test('everything in the exports map is published by the files map', () => { + for (const target of Object.values(pkg.exports)) { + assert.ok(pkg.files.includes(target.replace(/^\.\//, '')), `${target} is exported but missing from "files"`) + } +}) diff --git a/test/fixtures/project-basic/instruments.json b/test/fixtures/project-basic/instruments.json new file mode 100644 index 0000000..651c05a --- /dev/null +++ b/test/fixtures/project-basic/instruments.json @@ -0,0 +1,6 @@ +[ + { + "instrument_name": "demographics", + "instrument_label": "Demographics" + } +] diff --git a/test/fixtures/project-basic/metadata.json b/test/fixtures/project-basic/metadata.json new file mode 100644 index 0000000..433339f --- /dev/null +++ b/test/fixtures/project-basic/metadata.json @@ -0,0 +1,202 @@ +[ + { + "field_name": "record_id", + "form_name": "demographics", + "section_header": "", + "field_type": "text", + "field_label": "Record ID", + "select_choices_or_calculations": "", + "field_note": "", + "text_validation_type_or_show_slider_number": "", + "text_validation_min": "", + "text_validation_max": "", + "identifier": "", + "branching_logic": "", + "required_field": "", + "custom_alignment": "", + "question_number": "", + "matrix_group_name": "", + "matrix_ranking": "", + "field_annotation": "" + }, + { + "field_name": "name", + "form_name": "demographics", + "section_header": "", + "field_type": "text", + "field_label": "Full Name", + "select_choices_or_calculations": "", + "field_note": "", + "text_validation_type_or_show_slider_number": "", + "text_validation_min": "", + "text_validation_max": "", + "identifier": "y", + "branching_logic": "", + "required_field": "y", + "custom_alignment": "", + "question_number": "", + "matrix_group_name": "", + "matrix_ranking": "", + "field_annotation": "" + }, + { + "field_name": "age", + "form_name": "demographics", + "section_header": "", + "field_type": "text", + "field_label": "Age", + "select_choices_or_calculations": "", + "field_note": "years", + "text_validation_type_or_show_slider_number": "integer", + "text_validation_min": "0", + "text_validation_max": "120", + "identifier": "", + "branching_logic": "", + "required_field": "", + "custom_alignment": "", + "question_number": "", + "matrix_group_name": "", + "matrix_ranking": "", + "field_annotation": "" + }, + { + "field_name": "weight", + "form_name": "demographics", + "section_header": "", + "field_type": "text", + "field_label": "Weight", + "select_choices_or_calculations": "", + "field_note": "kg", + "text_validation_type_or_show_slider_number": "number_1dp", + "text_validation_min": "", + "text_validation_max": "", + "identifier": "", + "branching_logic": "", + "required_field": "", + "custom_alignment": "", + "question_number": "", + "matrix_group_name": "", + "matrix_ranking": "", + "field_annotation": "" + }, + { + "field_name": "dob", + "form_name": "demographics", + "section_header": "", + "field_type": "text", + "field_label": "Date of Birth", + "select_choices_or_calculations": "", + "field_note": "", + "text_validation_type_or_show_slider_number": "date_ymd", + "text_validation_min": "", + "text_validation_max": "", + "identifier": "y", + "branching_logic": "", + "required_field": "", + "custom_alignment": "", + "question_number": "", + "matrix_group_name": "", + "matrix_ranking": "", + "field_annotation": "" + }, + { + "field_name": "visit_dt", + "form_name": "demographics", + "section_header": "Visit", + "field_type": "text", + "field_label": "Visit Date/Time", + "select_choices_or_calculations": "", + "field_note": "", + "text_validation_type_or_show_slider_number": "datetime_ymd", + "text_validation_min": "", + "text_validation_max": "", + "identifier": "", + "branching_logic": "", + "required_field": "", + "custom_alignment": "", + "question_number": "", + "matrix_group_name": "", + "matrix_ranking": "", + "field_annotation": "" + }, + { + "field_name": "sex", + "form_name": "demographics", + "section_header": "", + "field_type": "radio", + "field_label": "Sex", + "select_choices_or_calculations": "1, Male | 2, Female | 3, Prefer not to say", + "field_note": "", + "text_validation_type_or_show_slider_number": "", + "text_validation_min": "", + "text_validation_max": "", + "identifier": "", + "branching_logic": "", + "required_field": "", + "custom_alignment": "", + "question_number": "", + "matrix_group_name": "", + "matrix_ranking": "", + "field_annotation": "" + }, + { + "field_name": "symptoms", + "form_name": "demographics", + "section_header": "", + "field_type": "checkbox", + "field_label": "Symptoms", + "select_choices_or_calculations": "1, Cough | 2, Fever | 3, Fatigue", + "field_note": "check all that apply", + "text_validation_type_or_show_slider_number": "", + "text_validation_min": "", + "text_validation_max": "", + "identifier": "", + "branching_logic": "", + "required_field": "", + "custom_alignment": "", + "question_number": "", + "matrix_group_name": "", + "matrix_ranking": "", + "field_annotation": "" + }, + { + "field_name": "consent", + "form_name": "demographics", + "section_header": "", + "field_type": "yesno", + "field_label": "Consented?", + "select_choices_or_calculations": "", + "field_note": "", + "text_validation_type_or_show_slider_number": "", + "text_validation_min": "", + "text_validation_max": "", + "identifier": "", + "branching_logic": "", + "required_field": "y", + "custom_alignment": "", + "question_number": "", + "matrix_group_name": "", + "matrix_ranking": "", + "field_annotation": "" + }, + { + "field_name": "bmi", + "form_name": "demographics", + "section_header": "", + "field_type": "calc", + "field_label": "BMI", + "select_choices_or_calculations": "[weight]/([height]*[height])", + "field_note": "", + "text_validation_type_or_show_slider_number": "", + "text_validation_min": "", + "text_validation_max": "", + "identifier": "", + "branching_logic": "", + "required_field": "", + "custom_alignment": "", + "question_number": "", + "matrix_group_name": "", + "matrix_ranking": "", + "field_annotation": "" + } +] diff --git a/test/fixtures/project-basic/project.json b/test/fixtures/project-basic/project.json new file mode 100644 index 0000000..3efc8ab --- /dev/null +++ b/test/fixtures/project-basic/project.json @@ -0,0 +1,28 @@ +{ + "project_id": 42, + "project_title": "Basic Test Project", + "creation_time": "2024-01-15 09:30:00", + "production_time": "", + "in_production": 0, + "project_language": "English", + "purpose": 1, + "purpose_other": "", + "project_notes": "a non-longitudinal project with a single instrument", + "custom_record_label": "", + "secondary_unique_field": "", + "is_longitudinal": 0, + "has_repeating_instruments_or_events": 0, + "surveys_enabled": 0, + "scheduling_enabled": 0, + "record_autonumbering_enabled": 1, + "randomization_enabled": 0, + "ddp_enabled": 0, + "project_irb_number": "", + "project_grant_number": "", + "project_pi_firstname": "", + "project_pi_lastname": "", + "display_today_now_button": 1, + "missing_data_codes": "", + "external_modules": "", + "bypass_branching_erase_field_prompt": 0 +} diff --git a/test/fixtures/project-basic/records-eav.json b/test/fixtures/project-basic/records-eav.json new file mode 100644 index 0000000..0350b08 --- /dev/null +++ b/test/fixtures/project-basic/records-eav.json @@ -0,0 +1,21 @@ +[ + { "record": "1", "field_name": "record_id", "value": "1" }, + { "record": "1", "field_name": "name", "value": "Ada Lovelace" }, + { "record": "1", "field_name": "age", "value": "36" }, + { "record": "1", "field_name": "weight", "value": "61.5" }, + { "record": "1", "field_name": "dob", "value": "1815-12-10" }, + { "record": "1", "field_name": "visit_dt", "value": "2024-03-01 14:30" }, + { "record": "1", "field_name": "sex", "value": "2" }, + { "record": "1", "field_name": "symptoms", "value": "1" }, + { "record": "1", "field_name": "symptoms", "value": "3" }, + { "record": "1", "field_name": "consent", "value": "1" }, + { "record": "1", "field_name": "bmi", "value": "22.4" }, + { "record": "1", "field_name": "demographics_complete", "value": "2" }, + { "record": "2", "field_name": "record_id", "value": "2" }, + { "record": "2", "field_name": "name", "value": "Grace Hopper" }, + { "record": "2", "field_name": "age", "value": "45" }, + { "record": "2", "field_name": "dob", "value": "1906-12-09" }, + { "record": "2", "field_name": "sex", "value": "2" }, + { "record": "2", "field_name": "consent", "value": "0" }, + { "record": "2", "field_name": "demographics_complete", "value": "0" } +] diff --git a/test/fixtures/project-longitudinal/arms.json b/test/fixtures/project-longitudinal/arms.json new file mode 100644 index 0000000..6965271 --- /dev/null +++ b/test/fixtures/project-longitudinal/arms.json @@ -0,0 +1,4 @@ +[ + { "arm_num": 1, "name": "Treatment" }, + { "arm_num": 2, "name": "Control" } +] diff --git a/test/fixtures/project-longitudinal/events.json b/test/fixtures/project-longitudinal/events.json new file mode 100644 index 0000000..ba3d4a4 --- /dev/null +++ b/test/fixtures/project-longitudinal/events.json @@ -0,0 +1,23 @@ +[ + { + "event_name": "Baseline", + "arm_num": 1, + "unique_event_name": "baseline_arm_1", + "custom_event_label": "", + "event_id": 101 + }, + { + "event_name": "Follow-up", + "arm_num": 1, + "unique_event_name": "followup_arm_1", + "custom_event_label": "", + "event_id": 102 + }, + { + "event_name": "Baseline", + "arm_num": 2, + "unique_event_name": "baseline_arm_2", + "custom_event_label": "", + "event_id": 201 + } +] diff --git a/test/fixtures/project-longitudinal/formEventMapping.json b/test/fixtures/project-longitudinal/formEventMapping.json new file mode 100644 index 0000000..5dda6b1 --- /dev/null +++ b/test/fixtures/project-longitudinal/formEventMapping.json @@ -0,0 +1,6 @@ +[ + { "arm_num": 1, "unique_event_name": "baseline_arm_1", "form": "demographics" }, + { "arm_num": 1, "unique_event_name": "baseline_arm_1", "form": "visit" }, + { "arm_num": 1, "unique_event_name": "followup_arm_1", "form": "visit" }, + { "arm_num": 2, "unique_event_name": "baseline_arm_2", "form": "demographics" } +] diff --git a/test/fixtures/project-longitudinal/instruments.json b/test/fixtures/project-longitudinal/instruments.json new file mode 100644 index 0000000..c265e7f --- /dev/null +++ b/test/fixtures/project-longitudinal/instruments.json @@ -0,0 +1,10 @@ +[ + { + "instrument_name": "demographics", + "instrument_label": "Demographics" + }, + { + "instrument_name": "visit", + "instrument_label": "Study Visit" + } +] diff --git a/test/fixtures/project-longitudinal/metadata.json b/test/fixtures/project-longitudinal/metadata.json new file mode 100644 index 0000000..c156aa6 --- /dev/null +++ b/test/fixtures/project-longitudinal/metadata.json @@ -0,0 +1,102 @@ +[ + { + "field_name": "record_id", + "form_name": "demographics", + "section_header": "", + "field_type": "text", + "field_label": "Record ID", + "select_choices_or_calculations": "", + "field_note": "", + "text_validation_type_or_show_slider_number": "", + "text_validation_min": "", + "text_validation_max": "", + "identifier": "", + "branching_logic": "", + "required_field": "", + "custom_alignment": "", + "question_number": "", + "matrix_group_name": "", + "matrix_ranking": "", + "field_annotation": "" + }, + { + "field_name": "sex", + "form_name": "demographics", + "section_header": "", + "field_type": "radio", + "field_label": "Sex", + "select_choices_or_calculations": "1, Male | 2, Female | 3, Prefer not to say", + "field_note": "", + "text_validation_type_or_show_slider_number": "", + "text_validation_min": "", + "text_validation_max": "", + "identifier": "", + "branching_logic": "", + "required_field": "", + "custom_alignment": "", + "question_number": "", + "matrix_group_name": "", + "matrix_ranking": "", + "field_annotation": "" + }, + { + "field_name": "visit_date", + "form_name": "visit", + "section_header": "", + "field_type": "text", + "field_label": "Visit Date", + "select_choices_or_calculations": "", + "field_note": "", + "text_validation_type_or_show_slider_number": "date_ymd", + "text_validation_min": "", + "text_validation_max": "", + "identifier": "", + "branching_logic": "", + "required_field": "", + "custom_alignment": "", + "question_number": "", + "matrix_group_name": "", + "matrix_ranking": "", + "field_annotation": "" + }, + { + "field_name": "systolic", + "form_name": "visit", + "section_header": "", + "field_type": "text", + "field_label": "Systolic BP", + "select_choices_or_calculations": "", + "field_note": "mmHg", + "text_validation_type_or_show_slider_number": "integer", + "text_validation_min": "", + "text_validation_max": "", + "identifier": "", + "branching_logic": "", + "required_field": "", + "custom_alignment": "", + "question_number": "", + "matrix_group_name": "", + "matrix_ranking": "", + "field_annotation": "" + }, + { + "field_name": "adverse", + "form_name": "visit", + "section_header": "", + "field_type": "yesno", + "field_label": "Adverse Event?", + "select_choices_or_calculations": "", + "field_note": "", + "text_validation_type_or_show_slider_number": "", + "text_validation_min": "", + "text_validation_max": "", + "identifier": "", + "branching_logic": "", + "required_field": "", + "custom_alignment": "", + "question_number": "", + "matrix_group_name": "", + "matrix_ranking": "", + "field_annotation": "" + } +] diff --git a/test/fixtures/project-longitudinal/project.json b/test/fixtures/project-longitudinal/project.json new file mode 100644 index 0000000..a451a0e --- /dev/null +++ b/test/fixtures/project-longitudinal/project.json @@ -0,0 +1,28 @@ +{ + "project_id": 43, + "project_title": "Longitudinal Test Project", + "creation_time": "2023-06-01 08:00:00", + "production_time": "2023-09-15 12:00:00", + "in_production": 1, + "project_language": "English", + "purpose": 2, + "purpose_other": "", + "project_notes": "two arms, two events, one repeating instrument", + "custom_record_label": "", + "secondary_unique_field": "", + "is_longitudinal": 1, + "has_repeating_instruments_or_events": 1, + "surveys_enabled": 1, + "scheduling_enabled": 0, + "record_autonumbering_enabled": 1, + "randomization_enabled": 0, + "ddp_enabled": 0, + "project_irb_number": "IRB-2023-0042", + "project_grant_number": "", + "project_pi_firstname": "Alan", + "project_pi_lastname": "Turing", + "display_today_now_button": 1, + "missing_data_codes": "", + "external_modules": "", + "bypass_branching_erase_field_prompt": 0 +} diff --git a/test/fixtures/project-longitudinal/records-eav.json b/test/fixtures/project-longitudinal/records-eav.json new file mode 100644 index 0000000..80d89f7 --- /dev/null +++ b/test/fixtures/project-longitudinal/records-eav.json @@ -0,0 +1,20 @@ +[ + { "record": "1", "redcap_event_name": "baseline_arm_1", "redcap_repeat_instrument": "", "redcap_repeat_instance": "", "field_name": "record_id", "value": "1" }, + { "record": "1", "redcap_event_name": "baseline_arm_1", "redcap_repeat_instrument": "", "redcap_repeat_instance": "", "field_name": "sex", "value": "1" }, + { "record": "1", "redcap_event_name": "baseline_arm_1", "redcap_repeat_instrument": "", "redcap_repeat_instance": "", "field_name": "demographics_complete", "value": "2" }, + { "record": "1", "redcap_event_name": "baseline_arm_1", "redcap_repeat_instrument": "visit", "redcap_repeat_instance": "1", "field_name": "visit_date", "value": "2023-10-02" }, + { "record": "1", "redcap_event_name": "baseline_arm_1", "redcap_repeat_instrument": "visit", "redcap_repeat_instance": "1", "field_name": "systolic", "value": "128" }, + { "record": "1", "redcap_event_name": "baseline_arm_1", "redcap_repeat_instrument": "visit", "redcap_repeat_instance": "1", "field_name": "adverse", "value": "0" }, + { "record": "1", "redcap_event_name": "baseline_arm_1", "redcap_repeat_instrument": "visit", "redcap_repeat_instance": "1", "field_name": "visit_complete", "value": "2" }, + { "record": "1", "redcap_event_name": "baseline_arm_1", "redcap_repeat_instrument": "visit", "redcap_repeat_instance": "2", "field_name": "visit_date", "value": "2023-10-16" }, + { "record": "1", "redcap_event_name": "baseline_arm_1", "redcap_repeat_instrument": "visit", "redcap_repeat_instance": "2", "field_name": "systolic", "value": "134" }, + { "record": "1", "redcap_event_name": "baseline_arm_1", "redcap_repeat_instrument": "visit", "redcap_repeat_instance": "2", "field_name": "adverse", "value": "1" }, + { "record": "1", "redcap_event_name": "baseline_arm_1", "redcap_repeat_instrument": "visit", "redcap_repeat_instance": "2", "field_name": "visit_complete", "value": "2" }, + { "record": "1", "redcap_event_name": "followup_arm_1", "redcap_repeat_instrument": "visit", "redcap_repeat_instance": "1", "field_name": "visit_date", "value": "2023-12-04" }, + { "record": "1", "redcap_event_name": "followup_arm_1", "redcap_repeat_instrument": "visit", "redcap_repeat_instance": "1", "field_name": "systolic", "value": "121" }, + { "record": "1", "redcap_event_name": "followup_arm_1", "redcap_repeat_instrument": "visit", "redcap_repeat_instance": "1", "field_name": "adverse", "value": "0" }, + { "record": "1", "redcap_event_name": "followup_arm_1", "redcap_repeat_instrument": "visit", "redcap_repeat_instance": "1", "field_name": "visit_complete", "value": "1" }, + { "record": "2", "redcap_event_name": "baseline_arm_2", "redcap_repeat_instrument": "", "redcap_repeat_instance": "", "field_name": "record_id", "value": "2" }, + { "record": "2", "redcap_event_name": "baseline_arm_2", "redcap_repeat_instrument": "", "redcap_repeat_instance": "", "field_name": "sex", "value": "2" }, + { "record": "2", "redcap_event_name": "baseline_arm_2", "redcap_repeat_instrument": "", "redcap_repeat_instance": "", "field_name": "demographics_complete", "value": "2" } +] diff --git a/test/fixtures/project-longitudinal/repeatingFormsEvents.json b/test/fixtures/project-longitudinal/repeatingFormsEvents.json new file mode 100644 index 0000000..98a7eb8 --- /dev/null +++ b/test/fixtures/project-longitudinal/repeatingFormsEvents.json @@ -0,0 +1,4 @@ +[ + { "event_name": "baseline_arm_1", "form_name": "visit", "custom_form_label": "" }, + { "event_name": "followup_arm_1", "form_name": "visit", "custom_form_label": "" } +]