From b3acd12e93fb081cc535a041bda6078e8027562c Mon Sep 17 00:00:00 2001 From: Stephen Cresswell <229672+cressie176@users.noreply.github.com> Date: Sun, 7 Jan 2024 08:48:56 +0000 Subject: [PATCH] Add more tests --- lib/helpers.js | 10 +- lib/marv-rdf-driver.js | 7 +- lib/schema.json | 127 +++--- lib/template.hbs | 8 +- test/TestReferenceDataFramework.js | 17 +- test/api.test.js | 174 ++++++++ test/dsl.test.js | 217 +++++++++- test/index.test.js | 482 --------------------- test/migrations/.marvrc | 6 - test/migrations/001.define-tax-schema.yaml | 19 - test/notifications.test.js | 166 +++++++ test/schema.test.js | 143 ++++++ 12 files changed, 792 insertions(+), 584 deletions(-) create mode 100644 test/api.test.js delete mode 100644 test/index.test.js delete mode 100644 test/migrations/.marvrc delete mode 100644 test/migrations/001.define-tax-schema.yaml create mode 100644 test/notifications.test.js create mode 100644 test/schema.test.js diff --git a/lib/helpers.js b/lib/helpers.js index a8086b1..6c2f77d 100644 --- a/lib/helpers.js +++ b/lib/helpers.js @@ -1,17 +1,9 @@ module.exports = { + tableName: (name, version) => `${name.toLowerCase().replace(/\s/g, '_')}_v${version}`, xkeys: (obj, options) => Object.keys(obj).reduce(toString.bind(options), ''), xvalues: (obj, options) => Object.values(obj).reduce(toString.bind(options), ''), - eq: (a, b) => a === b, - ne: (a, b) => a !== b, - lt: (a, b) => a < b, - gt: (a, b) => a > b, - lte: (a, b) => a <= b, - gte: (a, b) => a >= b, and() { return Array.prototype.every.call(arguments, Boolean); - }, - or() { - return Array.prototype.slice.call(arguments, 0, -1).some(Boolean); } } diff --git a/lib/marv-rdf-driver.js b/lib/marv-rdf-driver.js index a77e58b..f6b0dc3 100644 --- a/lib/marv-rdf-driver.js +++ b/lib/marv-rdf-driver.js @@ -57,12 +57,14 @@ module.exports = (options) => { function createValidationError(validate) { let message; - const errors = validate.errors; //.filter((e => e.keyword !== 'oneOf')) + const errors = validate.errors; const instancePath = errors[0].instancePath || 'migration script'; switch (errors[0].keyword) { case 'required': { - const missingProperties = errors.map(e => `'${e.params.missingProperty}'`).join(' or '); + const missingProperties = errors.filter(e => e.keyword === 'required') + .map(e => `'${e.params.missingProperty}'`) + .join(' or '); message = `${instancePath} must have required property ${missingProperties}`; break; } @@ -76,7 +78,6 @@ module.exports = (options) => { function decorateMigrationScript(script) { script.define_entities?.forEach((entity) => { - entity.table_name = entity.name.toLowerCase().replace(/\s/g, '_'); entity.identified_by = entity.fields.filter((field) => { return entity.identified_by.includes(field.name); }); diff --git a/lib/schema.json b/lib/schema.json index 5636a79..24412c1 100644 --- a/lib/schema.json +++ b/lib/schema.json @@ -95,6 +95,13 @@ "type": "string" } }, + "identified_by": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + }, "checks": { "type": "object", "minProperties": 1, @@ -197,67 +204,73 @@ } }, "changeSetType": { - "type": "object", - "properties": { - "effective_from": { - "type": "string", - "format": "date-time" - }, - "notes": { - "type": "string" - }, - "frames": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "properties": { - "entity": { - "type": "string" - }, - "version": { - "type": "integer" - }, - "action": { - "type": "string", - "enum": [ - "POST", - "DELETE" - ] - }, - "data": { - "type": "array", - "minItems": 1, - "items": { - "type": "object" + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "effective from": { + "type": "string", + "format": "date-time" + }, + "effective_from": { + "type": "string", + "format": "date-time" + }, + "notes": { + "type": "string" + }, + "frames": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "entity": { + "type": "string" + }, + "version": { + "type": "integer" + }, + "action": { + "type": "string", + "enum": [ + "POST", + "DELETE" + ] + }, + "data": { + "type": "array", + "minItems": 1, + "items": { + "type": "object" + } } - } - }, + }, + "required": [ + "entity", + "version", + "action", + "data" + ] + } + } + }, + "oneOf": [ + { "required": [ - "entity", - "version", - "action", - "data" + "effective from", + "frames" + ] + }, + { + "required": [ + "effective_from", + "frames" ] } - } - }, - "oneOf": [ - { - "required": [ - "effective_from", - "notes", - "frames" - ] - }, - { - "required": [ - "effective from", - "notes", - "frames" - ] - } - ] + ] + } } }, "required": [] diff --git a/lib/template.hbs b/lib/template.hbs index b555954..ea58b1f 100644 --- a/lib/template.hbs +++ b/lib/template.hbs @@ -11,7 +11,7 @@ CREATE TYPE {{name}} AS ENUM ( {{#define_entities}} INSERT INTO rdf_entity (name, version) VALUES ('{{name}}', {{version}}); -CREATE TABLE {{table_name}}_v{{version}} ( +CREATE TABLE {{tableName name version}} ( rdf_frame_id INTEGER PRIMARY KEY REFERENCES rdf_data_frame (id), {{#fields}} {{name}} {{type}}, @@ -22,7 +22,7 @@ CREATE TABLE {{table_name}}_v{{version}} ( {{/each}} ); -CREATE FUNCTION get_{{table_name}}_v{{version}}_aggregate( +CREATE FUNCTION get_{{tableName name version}}_aggregate( p_change_set_id INTEGER ) RETURNS TABLE ( {{#fields}} @@ -42,7 +42,7 @@ BEGIN FROM rdf_data_frame f INNER JOIN rdf_entity e ON e.id = f.entity_id - INNER JOIN {{table_name}}_v{{version}} x ON x.rdf_frame_id = f.id + INNER JOIN {{tableName name version}} x ON x.rdf_frame_id = f.id WHERE e.name = '{{name}}' AND e.version = {{version}} AND f.change_set_id <= p_change_set_id ORDER BY @@ -120,7 +120,7 @@ DO $$ (v_change_set_id, v_entity_id, '{{../action}}') RETURNING id INTO v_frame_id; - INSERT INTO {{../entity}}_v{{../version}} (rdf_frame_id, {{#xkeys .}}{{item}}{{#unless isLast}}, {{/unless}}{{/xkeys}}) VALUES + INSERT INTO {{tableName ../entity ../version}} (rdf_frame_id, {{#xkeys .}}{{item}}{{#unless isLast}}, {{/unless}}{{/xkeys}}) VALUES (v_frame_id, {{#xvalues .}}'{{item}}'{{#unless isLast}}, {{/unless}}{{/xvalues}}); {{/data}} diff --git a/test/TestReferenceDataFramework.js b/test/TestReferenceDataFramework.js index 0eae180..e37081b 100644 --- a/test/TestReferenceDataFramework.js +++ b/test/TestReferenceDataFramework.js @@ -1,4 +1,5 @@ const ReferenceDataFramework = require('..'); +const noop = () => { }; module.exports = class TestReferenceDataFramework extends ReferenceDataFramework { @@ -7,8 +8,8 @@ module.exports = class TestReferenceDataFramework extends ReferenceDataFramework constructor(config) { super(config); - this.#nukeCustomObjects = config.nukeCustomObjects; - this.#wipeCustomData = config.wipeCustomData; + this.#nukeCustomObjects = config.nukeCustomObjects || noop; + this.#wipeCustomData = config.wipeCustomData || noop; } async reset() { @@ -26,6 +27,18 @@ module.exports = class TestReferenceDataFramework extends ReferenceDataFramework }) } + async nukeCustomObjects() { + return this.withTransaction((tx) => { + return this.#nukeCustomObjects(tx); + }) + } + + async wipeRdfData() { + return this.withTransaction((tx) => { + return this.#wipeRdfData(tx); + }) + } + async #wipeRdfData(tx) { await tx.query('DELETE FROM rdf_notification'); await tx.query('DELETE FROM rdf_hook'); diff --git a/test/api.test.js b/test/api.test.js new file mode 100644 index 0000000..8ead992 --- /dev/null +++ b/test/api.test.js @@ -0,0 +1,174 @@ +const { ok, strictEqual: eq, deepEqual: deq, rejects, match } = require('node:assert'); +const { describe, it, before, beforeEach, after, afterEach } = require('zunit'); + +const TestReferenceDataFramework = require('./TestReferenceDataFramework'); + +const config = { + migrations: 'test', + database: { + user: 'rdf_test', + password: 'rdf_test' + }, + notifications: { + initialDelay: '0ms', + interval: '100ms', + maxAttempts: 3, + maxRescheduleDelay: '100ms', + }, + nukeCustomObjects: async (tx) => { + await tx.query('DROP TABLE IF EXISTS vat_rate_v1'); + await tx.query('DROP FUNCTION IF EXISTS get_vat_rate_v1_aggregate'); + await tx.query('DROP TYPE IF EXISTS tax_rate_type'); + }, + wipeCustomData: async (tx) => { + await tx.query('DELETE FROM vat_rate_v1'); + } +} + +describe('API', () => { + + let rdf; + + before(async () => { + rdf = new TestReferenceDataFramework(config); + await rdf.init(); + await rdf.nukeCustomObjects(); + await rdf.wipeRdfData(); + }) + + beforeEach(async () => { + rdf.removeAllListeners(); + await rdf.nukeCustomObjects(); + await rdf.wipeRdfData(); + }) + + afterEach(async () => { + await rdf.stopNotifications(); + rdf.removeAllListeners(); + }) + + after(async () => { + await rdf.stop(); + }) + + describe('Projections', () => { + + it('should list projections', async () => { + await rdf.withTransaction(async (tx) => { + await tx.query(`INSERT INTO rdf_projection VALUES + (1, 'VAT Rates', 1), + (2, 'VAT Rates', 2), + (3, 'CGT Rates', 1)` + ); + }); + + const projections = await rdf.getProjections(); + eq(projections.length, 3); + deq(projections[0], { id: 1, name: 'VAT Rates', version: 1 }); + deq(projections[1], { id: 2, name: 'VAT Rates', version: 2 }); + deq(projections[2], { id: 3, name: 'CGT Rates', version: 1 }); + }); + + it('should get projection by name and version', async () => { + await rdf.withTransaction(async (tx) => { + await tx.query(`INSERT INTO rdf_projection VALUES + (1, 'VAT Rates', 1), + (2, 'VAT Rates', 2), + (3, 'CGT Rates', 1)` + ); + }); + + const projection = await rdf.getProjection('VAT Rates', 2); + deq(projection, { id: 2, name: 'VAT Rates', version: 2 }); + }); + }); + + describe('Change Sets', () => { + + it('should list change sets for the given projection', async () => { + await rdf.withTransaction(async (tx) => { + await tx.query(`INSERT INTO rdf_projection (id, name, version) VALUES + (1, 'VAT Rates', 1), + (2, 'CGT Rates', 1)` + ); + await tx.query(`INSERT INTO rdf_entity (id, name, version) VALUES + (1, 'Country', 1), + (2, 'VAT Rate', 1), + (3, 'CGT Rate', 1) + `); + await tx.query(`INSERT INTO rdf_projection_entity (projection_id, entity_id) VALUES + (1, 1), + (1, 2), + (2, 1), + (2, 3)` + ); + await tx.query(`INSERT INTO rdf_change_set (id, effective_from, notes) VALUES + (1, '2020-04-05T00:00:00.000Z', 'Countries'), + (2, '2020-04-05T00:00:00.000Z', '2020 VAT Rates'), + (3, '2020-04-05T00:00:00.000Z', '2020 CGT Rates'), + (4, '2021-04-05T00:00:00.000Z', '2021 VAT Rates'), + (5, '2021-04-05T00:00:00.000Z', '2021 CGT Rates')` + ); + await tx.query(`INSERT INTO rdf_data_frame (change_set_id, entity_id, action) VALUES + (1, 1, 'POST'), + (2, 2, 'POST'), + (3, 3, 'POST'), + (4, 2, 'POST'), + (5, 3, 'POST')` + ); + }); + + const projection = await rdf.getProjection('VAT Rates', 1); + const changelog = (await rdf.getChangeLog(projection)).map(({ id, effectiveFrom, notes }) => ({ id, effectiveFrom: effectiveFrom.toISOString(), notes })); + + eq(changelog.length, 3); + deq(changelog[0], { id: 1, effectiveFrom: '2020-04-05T00:00:00.000Z', notes: 'Countries' }); + deq(changelog[1], { id: 2, effectiveFrom: '2020-04-05T00:00:00.000Z', notes: '2020 VAT Rates' }); + deq(changelog[2], { id: 4, effectiveFrom: '2021-04-05T00:00:00.000Z', notes: '2021 VAT Rates' }); + }); + + it('should dedupe change sets', async () => { + await rdf.withTransaction(async (tx) => { + await tx.query(`INSERT INTO rdf_projection (id, name, version) VALUES + (1, 'VAT Rates', 1)` + ); + await tx.query(`INSERT INTO rdf_entity (id, name, version) VALUES + (1, 'Country', 1), + (2, 'VAT Rate', 1) + `); + await tx.query(`INSERT INTO rdf_projection_entity (projection_id, entity_id) VALUES + (1, 1), + (1, 2)` + ); + await tx.query(`INSERT INTO rdf_change_set (id, effective_from, notes) VALUES + (1, '2020-04-05T00:00:00.000Z', 'Everything')` + ); + await tx.query(`INSERT INTO rdf_data_frame (change_set_id, entity_id, action) VALUES + (1, 1, 'POST'), + (1, 2, 'POST'), + (1, 2, 'POST')` + ); + }); + + const projection = await rdf.getProjection('VAT Rates', 1); + const changelog = (await rdf.getChangeLog(projection)).map(({ id, effectiveFrom, notes }) => ({ id, effectiveFrom: effectiveFrom.toISOString(), notes })); + eq(changelog.length, 1); + deq(changelog[0], { id: 1, effectiveFrom: '2020-04-05T00:00:00.000Z', notes: 'Everything' }); + }); + + it('should get change set by id', async () => { + await rdf.withTransaction(async (tx) => { + await tx.query(`INSERT INTO rdf_change_set (id, effective_from, notes) VALUES + (1, '2020-04-05T00:00:00.000Z', 'Countries'), + (2, '2020-04-05T00:00:00.000Z', '2020 VAT Rates'), + (3, '2020-04-05T00:00:00.000Z', '2020 CGT Rates')` + ); + }); + + const changeSet = await rdf.getChangeSet(2); + eq(changeSet.id, 2); + eq(changeSet.effectiveFrom.toISOString(), '2020-04-05T00:00:00.000Z'); + eq(changeSet.notes, '2020 VAT Rates'); + }); + }); +}); diff --git a/test/dsl.test.js b/test/dsl.test.js index 0fec103..14b97dd 100644 --- a/test/dsl.test.js +++ b/test/dsl.test.js @@ -39,12 +39,15 @@ describe('DSL', () => { before(async () => { deleteMigrations(); rdf = new TestReferenceDataFramework(config); - await rdf.reset(); + await rdf.init(); + await rdf.nukeCustomObjects(); + await rdf.wipeRdfData(); }) beforeEach(async () => { deleteMigrations(); - await rdf.reset(); + await rdf.nukeCustomObjects(); + await rdf.wipeRdfData(); }) after(async () => { @@ -103,6 +106,216 @@ describe('DSL', () => { eq(entities.length, 1); deq(entities[0], { name: 'VAT Rate', version: 1 }); }); + + it('should aggregate data frames up to the specified change set', async (t) => { + await apply(t.name, ` + add projections: + - name: VAT Rates + version: 1 + dependencies: + - entity: VAT Rate + version: 1 + + define entities: + - name: VAT Rate + version: 1 + fields: + - name: type + type: TEXT + - name: rate + type: NUMERIC + identified by: + - type + + add change set: + - notes: 2020 VAT Rates + effective from: 2020-04-05T00:00:00.000Z + frames: + - entity: VAT Rate + version: 1 + action: POST + data: + - type: standard + rate: 0.10 + - entity: VAT Rate + version: 1 + action: POST + data: + - type: reduced + rate: 0.05 + - entity: VAT Rate + version: 1 + action: POST + data: + - type: zero + rate: 0 + + - notes: 2021 VAT Rates + effective from: 2021-04-05T00:00:00.000Z + frames: + - entity: VAT Rate + version: 1 + action: POST + data: + - type: standard + rate: 0.125 + - entity: VAT Rate + version: 1 + action: POST + data: + - type: reduced + rate: 0.07 + - entity: VAT Rate + version: 1 + action: POST + data: + - type: zero + rate: 0 + + - notes: 2022 VAT Rates + effective from: 2022-04-05T00:00:00.000Z + frames: + - entity: VAT Rate + version: 1 + action: POST + data: + - type: standard + rate: 0.15 + - entity: VAT Rate + version: 1 + action: POST + data: + - type: reduced + rate: 0.10 + - entity: VAT Rate + version: 1 + action: POST + data: + - type: zero + rate: 0 + `); + + const projection = await rdf.getProjection('VAT Rates', 1); + const changeLog = await rdf.getChangeLog(projection); + + await rdf.withTransaction(async (tx) => { + const { rows: aggregate1 } = await tx.query('SELECT * FROM get_vat_rate_v1_aggregate($1) ORDER BY rate DESC', [changeLog[0].id]); + eq(aggregate1.length, 3); + deq(aggregate1[0], { type: 'standard', rate: 0.10 }); + deq(aggregate1[1], { type: 'reduced', rate: 0.05 }); + deq(aggregate1[2], { type: 'zero', rate: 0 }); + + const { rows: aggregate3 } = await tx.query('SELECT * FROM get_vat_rate_v1_aggregate($1) ORDER BY rate DESC NULLS LAST', [changeLog[2].id]); + eq(aggregate3.length, 3); + deq(aggregate3[0], { type: 'standard', rate: 0.15 }); + deq(aggregate3[1], { type: 'reduced', rate: 0.10 }); + deq(aggregate1[2], { type: 'zero', rate: 0 }); + }) + }); + + it('should exclude aggregates where the most recent frame was a delete', async (t) => { + await apply(t.name, ` + add projections: + - name: VAT Rates + version: 1 + dependencies: + - entity: VAT Rate + version: 1 + + define entities: + - name: VAT Rate + version: 1 + fields: + - name: type + type: TEXT + - name: rate + type: NUMERIC + identified by: + - type + + add change set: + - notes: 2020 VAT Rates + effective from: 2020-04-05T00:00:00.000Z + frames: + - entity: VAT Rate + version: 1 + action: POST + data: + - type: standard + rate: 0.10 + - entity: VAT Rate + version: 1 + action: POST + data: + - type: reduced + rate: 0.05 + - entity: VAT Rate + version: 1 + action: POST + data: + - type: zero + rate: 0 + + - notes: 2021 VAT Rates + effective from: 2021-04-05T00:00:00.000Z + frames: + - entity: VAT Rate + version: 1 + action: POST + data: + - type: standard + rate: 0.125 + - entity: VAT Rate + version: 1 + action: POST + data: + - type: reduced + rate: 0.07 + - entity: VAT Rate + version: 1 + action: POST + data: + - type: zero + rate: 0 + + - notes: 2022 VAT Rates + effective from: 2022-04-05T00:00:00.000Z + frames: + - entity: VAT Rate + version: 1 + action: POST + data: + - type: standard + rate: 0.15 + - entity: VAT Rate + version: 1 + action: POST + data: + - type: reduced + rate: 0.10 + - entity: VAT Rate + version: 1 + action: DELETE + data: + - type: zero + `); + + const projection = await rdf.getProjection('VAT Rates', 1); + const changeLog = await rdf.getChangeLog(projection); + + await rdf.withTransaction(async (tx) => { + const { rows: aggregate1 } = await tx.query('SELECT * FROM get_vat_rate_v1_aggregate($1) ORDER BY rate DESC', [changeLog[0].id]); + eq(aggregate1.length, 3); + deq(aggregate1[0], { type: 'standard', rate: 0.10 }); + deq(aggregate1[1], { type: 'reduced', rate: 0.05 }); + deq(aggregate1[2], { type: 'zero', rate: 0 }); + + const { rows: aggregate3 } = await tx.query('SELECT * FROM get_vat_rate_v1_aggregate($1) ORDER BY rate DESC NULLS LAST', [changeLog[2].id]); + eq(aggregate3.length, 2); + deq(aggregate3[0], { type: 'standard', rate: 0.15 }); + deq(aggregate3[1], { type: 'reduced', rate: 0.10 }); + }); + }); }); describe('Hooks', () => { diff --git a/test/index.test.js b/test/index.test.js deleted file mode 100644 index eaf8838..0000000 --- a/test/index.test.js +++ /dev/null @@ -1,482 +0,0 @@ -const { ok, strictEqual: eq, deepEqual: deq, rejects, match } = require('node:assert'); -const { describe, it, before, beforeEach, after, afterEach } = require('zunit'); - -const TestReferenceDataFramework = require('./TestReferenceDataFramework'); - -const config = { - migrations: 'test/migrations', - database: { - user: 'rdf_test', - password: 'rdf_test' - }, - notifications: { - initialDelay: '0ms', - interval: '100ms', - maxAttempts: 3, - maxRescheduleDelay: '100ms', - }, - nukeCustomObjects: async (tx) => { - await tx.query('DROP TABLE IF EXISTS vat_rate_v1'); - await tx.query('DROP FUNCTION IF EXISTS get_vat_rate_v1_aggregate'); - await tx.query('DROP TYPE IF EXISTS tax_rate_type'); - }, - wipeCustomData: async (tx) => { - await tx.query('DELETE FROM vat_rate_v1'); - } -} - -describe('RDF', () => { - - let rdf; - - before(async () => { - rdf = new TestReferenceDataFramework(config); - await rdf.reset(); - }) - - beforeEach(async () => { - rdf.removeAllListeners(); - await rdf.wipe(); - }) - - afterEach(async () => { - await rdf.stopNotifications(); - rdf.removeAllListeners(); - }) - - after(async () => { - await rdf.stop(); - }) - - describe('Projections', () => { - it('should prevent duplicate projections', async () => { - - await rdf.withTransaction(async (tx) => { - await tx.query("INSERT INTO rdf_projection (name, version) VALUES ('NOT DUPLICATE', 1)"); - await tx.query("INSERT INTO rdf_projection (name, version) VALUES ('NOT DUPLICATE', 2)"); - - await tx.query("INSERT INTO rdf_projection (name, version) VALUES ('NOT DUPLICATE A', 1)"); - await tx.query("INSERT INTO rdf_projection (name, version) VALUES ('NOT DUPLICATE B', 1)"); - }); - - await rejects(async () => { - await rdf.withTransaction(async (tx) => { - await tx.query("INSERT INTO rdf_projection (name, version) VALUES ('DUPLICATE', 1)"); - await tx.query("INSERT INTO rdf_projection (name, version) VALUES ('DUPLICATE', 1)"); - }); - }, (err) => { - eq(err.code, '23505'); - return true; - }) - }); - - it('should enforce projections are named', async () => { - await rejects(async () => { - await rdf.withTransaction(async (tx) => { - await tx.query("INSERT INTO rdf_projection (name, version) VALUES (NULL, 1)"); - }); - }, (err) => { - eq(err.code, '23502'); - return true; - }) - }); - - it('should enforce projections are versioned', async () => { - await rejects(async () => { - await rdf.withTransaction(async (tx) => { - await tx.query("INSERT INTO rdf_projection (name, version) VALUES ('OK', NULL)"); - }); - }, (err) => { - eq(err.code, '23502'); - return true; - }) - }); - - it('should list projections', async () => { - await rdf.withTransaction(async (tx) => { - await tx.query(`INSERT INTO rdf_projection VALUES - (1, 'VAT Rates', 1), - (2, 'VAT Rates', 2), - (3, 'CGT Rates', 1)` - ); - }); - - const projections = await rdf.getProjections(); - eq(projections.length, 3); - deq(projections[0], { id: 1, name: 'VAT Rates', version: 1 }); - deq(projections[1], { id: 2, name: 'VAT Rates', version: 2 }); - deq(projections[2], { id: 3, name: 'CGT Rates', version: 1 }); - }); - - it('should get projection by name and version', async () => { - await rdf.withTransaction(async (tx) => { - await tx.query(`INSERT INTO rdf_projection VALUES - (1, 'VAT Rates', 1), - (2, 'VAT Rates', 2), - (3, 'CGT Rates', 1)` - ); - }); - - const projection = await rdf.getProjection('VAT Rates', 2); - deq(projection, { id: 2, name: 'VAT Rates', version: 2 }); - }); - }); - - describe('Change Sets', () => { - it('should prevent duplicate change sets', async () => { - - await rdf.withTransaction(async (tx) => { - await tx.query(`INSERT INTO rdf_change_set (id, effective_from) VALUES - (1, '2023-01-01T00:00:00.000Z'), - (2, '2023-01-01T00:00:00.000Z') - `); - }); - - await rejects(async () => { - await rdf.withTransaction(async (tx) => { - await tx.query(`INSERT INTO rdf_change_set (id, effective_from) VALUES - (3, '2023-01-01T00:00:00.000Z'), - (3, '2023-01-01T00:00:00.000Z')` - ); - }); - }, (err) => { - eq(err.code, '23505'); - return true; - }) - }); - - it('should enforce change sets have effective_from dates', async () => { - await rejects(async () => { - await rdf.withTransaction(async (tx) => { - await tx.query("INSERT INTO rdf_change_set (id, effective_from) VALUES (1, NULL)"); - }); - }, (err) => { - eq(err.code, '23502'); - return true; - }) - }); - - it('should list change sets for the given projection', async () => { - await rdf.withTransaction(async (tx) => { - await tx.query(`INSERT INTO rdf_projection (id, name, version) VALUES - (1, 'VAT Rates', 1), - (2, 'CGT Rates', 1)` - ); - await tx.query(`INSERT INTO rdf_entity (id, name, version) VALUES - (1, 'Country', 1), - (2, 'VAT Rate', 1), - (3, 'CGT Rate', 1) - `); - await tx.query(`INSERT INTO rdf_projection_entity (projection_id, entity_id) VALUES - (1, 1), - (1, 2), - (2, 1), - (2, 3)` - ); - await tx.query(`INSERT INTO rdf_change_set (id, effective_from, notes) VALUES - (1, '2020-04-05T00:00:00.000Z', 'Countries'), - (2, '2020-04-05T00:00:00.000Z', '2020 VAT Rates'), - (3, '2020-04-05T00:00:00.000Z', '2020 CGT Rates'), - (4, '2021-04-05T00:00:00.000Z', '2021 VAT Rates'), - (5, '2021-04-05T00:00:00.000Z', '2021 CGT Rates')` - ); - await tx.query(`INSERT INTO rdf_data_frame (change_set_id, entity_id, action) VALUES - (1, 1, 'POST'), - (2, 2, 'POST'), - (3, 3, 'POST'), - (4, 2, 'POST'), - (5, 3, 'POST')` - ); - }); - - const projection = await rdf.getProjection('VAT Rates', 1); - const changelog = (await rdf.getChangeLog(projection)).map(({ id, effectiveFrom, notes }) => ({ id, effectiveFrom: effectiveFrom.toISOString(), notes })); - - eq(changelog.length, 3); - deq(changelog[0], { id: 1, effectiveFrom: '2020-04-05T00:00:00.000Z', notes: 'Countries' }); - deq(changelog[1], { id: 2, effectiveFrom: '2020-04-05T00:00:00.000Z', notes: '2020 VAT Rates' }); - deq(changelog[2], { id: 4, effectiveFrom: '2021-04-05T00:00:00.000Z', notes: '2021 VAT Rates' }); - }); - - it('should dedupe change sets', async () => { - await rdf.withTransaction(async (tx) => { - await tx.query(`INSERT INTO rdf_projection (id, name, version) VALUES - (1, 'VAT Rates', 1)` - ); - await tx.query(`INSERT INTO rdf_entity (id, name, version) VALUES - (1, 'Country', 1), - (2, 'VAT Rate', 1) - `); - await tx.query(`INSERT INTO rdf_projection_entity (projection_id, entity_id) VALUES - (1, 1), - (1, 2)` - ); - await tx.query(`INSERT INTO rdf_change_set (id, effective_from, notes) VALUES - (1, '2020-04-05T00:00:00.000Z', 'Everything')` - ); - await tx.query(`INSERT INTO rdf_data_frame (change_set_id, entity_id, action) VALUES - (1, 1, 'POST'), - (1, 2, 'POST'), - (1, 2, 'POST')` - ); - }); - - const projection = await rdf.getProjection('VAT Rates', 1); - const changelog = (await rdf.getChangeLog(projection)).map(({ id, effectiveFrom, notes }) => ({ id, effectiveFrom: effectiveFrom.toISOString(), notes })); - eq(changelog.length, 1); - deq(changelog[0], { id: 1, effectiveFrom: '2020-04-05T00:00:00.000Z', notes: 'Everything' }); - }); - - it('should get change set by id', async () => { - await rdf.withTransaction(async (tx) => { - await tx.query(`INSERT INTO rdf_change_set (id, effective_from, notes) VALUES - (1, '2020-04-05T00:00:00.000Z', 'Countries'), - (2, '2020-04-05T00:00:00.000Z', '2020 VAT Rates'), - (3, '2020-04-05T00:00:00.000Z', '2020 CGT Rates')` - ); - }); - - const changeSet = await rdf.getChangeSet(2); - eq(changeSet.id, 2); - eq(changeSet.effectiveFrom.toISOString(), '2020-04-05T00:00:00.000Z'); - eq(changeSet.notes, '2020 VAT Rates'); - }); - - it('should default last modified date to now', async () => { - const before = new Date(); - - await rdf.withTransaction(async (tx) => { - await tx.query(`INSERT INTO rdf_change_set (id, effective_from, notes) VALUES - (1, '2020-04-05T00:00:00.000Z', 'Countries')` - ); - }); - - const changeSet = await rdf.getChangeSet(1); - ok(changeSet.lastModified >= before); - }); - - it('should default entity tag to random hex', async () => { - await rdf.withTransaction(async (tx) => { - await tx.query(`INSERT INTO rdf_change_set (id, effective_from, notes) VALUES - (1, '2020-04-05T00:00:00.000Z', 'Countries')` - ); - }); - - const changeSet = await rdf.getChangeSet(1); - match(changeSet.entityTag, /^[a-f|0-9]{20}$/); - }); - }); - - describe('Aggregates', () => { - it('should aggregate data frames up to the specified change set', async () => { - await rdf.withTransaction(async (tx) => { - await tx.query(`INSERT INTO rdf_entity (id, name, version) VALUES - (1, 'VAT Rate', 1) - `); - await tx.query(`INSERT INTO rdf_change_set (id, effective_from, notes) VALUES - (1, '2020-04-05T00:00:00.000Z', '2020 VAT Rates'), - (2, '2021-04-05T00:00:00.000Z', '2021 VAT Rates'), - (3, '2022-04-05T00:00:00.000Z', '2022 VAT Rates')` - ); - await tx.query(`INSERT INTO rdf_data_frame (id, change_set_id, entity_id, action) VALUES - (1, 1, 1, 'POST'), - (2, 1, 1, 'POST'), - (3, 1, 1, 'POST'), - (4, 2, 1, 'POST'), - (5, 2, 1, 'POST'), - (6, 2, 1, 'POST'), - (7, 3, 1, 'POST'), - (8, 3, 1, 'POST'), - (9, 3, 1, 'POST')` - ); - await tx.query(`INSERT INTO vat_rate_v1 (rdf_frame_id, type, rate) VALUES - (1, 'standard', 0.10), - (2, 'reduced', 0.05), - (3, 'zero', 0), - (4, 'standard', 0.125), - (5, 'reduced', 0.7), - (6, 'zero', 0), - (7, 'standard', 0.15), - (8, 'reduced', 0.10), - (9, 'zero', 0)` - ); - - const { rows: aggregate1 } = await tx.query('SELECT * FROM get_vat_rate_v1_aggregate($1)', [1]); - eq(aggregate1.length, 3); - deq(aggregate1[0], { type: 'standard', rate: 0.10 }); - deq(aggregate1[1], { type: 'reduced', rate: 0.05 }); - deq(aggregate1[2], { type: 'zero', rate: 0 }); - - const { rows: aggregate3 } = await tx.query('SELECT * FROM get_vat_rate_v1_aggregate($1)', [3]); - eq(aggregate3.length, 3); - deq(aggregate3[0], { type: 'standard', rate: 0.15 }); - deq(aggregate3[1], { type: 'reduced', rate: 0.10 }); - }); - }); - - it('should exclude aggregates where the most recent frame was a delete', async () => { - await rdf.withTransaction(async (tx) => { - await tx.query(`INSERT INTO rdf_entity (id, name, version) VALUES - (1, 'VAT Rate', 1) - `); - await tx.query(`INSERT INTO rdf_change_set (id, effective_from, notes) VALUES - (1, '2020-04-05T00:00:00.000Z', '2020 VAT Rates'), - (2, '2021-04-05T00:00:00.000Z', '2021 VAT Rates'), - (3, '2022-04-05T00:00:00.000Z', '2022 VAT Rates')` - ); - await tx.query(`INSERT INTO rdf_data_frame (id, change_set_id, entity_id, action) VALUES - (1, 1, 1, 'POST'), - (2, 1, 1, 'POST'), - (3, 1, 1, 'POST'), - (4, 2, 1, 'POST'), - (5, 2, 1, 'POST'), - (6, 2, 1, 'POST'), - (7, 3, 1, 'POST'), - (8, 3, 1, 'POST'), - (9, 3, 1, 'DELETE')` - ); - await tx.query(`INSERT INTO vat_rate_v1 (rdf_frame_id, type, rate) VALUES - (1, 'standard', 0.10), - (2, 'reduced', 0.05), - (3, 'zero', 0), - (4, 'standard', 0.125), - (5, 'reduced', 0.7), - (6, 'zero', 0), - (7, 'standard', 0.15), - (8, 'reduced', 0.10)` - ); - await tx.query(`INSERT INTO vat_rate_v1 (rdf_frame_id, type) VALUES - (9, 'zero')` - ); - - const { rows: aggregate1 } = await tx.query('SELECT * FROM get_vat_rate_v1_aggregate($1)', [1]); - eq(aggregate1.length, 3); - deq(aggregate1[0], { type: 'standard', rate: 0.10 }); - deq(aggregate1[1], { type: 'reduced', rate: 0.05 }); - deq(aggregate1[2], { type: 'zero', rate: 0 }); - - const { rows: aggregate3 } = await tx.query('SELECT * FROM get_vat_rate_v1_aggregate($1)', [3]); - eq(aggregate3.length, 2); - deq(aggregate3[0], { type: 'standard', rate: 0.15 }); - deq(aggregate3[1], { type: 'reduced', rate: 0.10 }); - }); - }); - }) - - describe('Notifications', () => { - it('should notify interested parties of projection changes', async (t, done) => { - await rdf.withTransaction(async (tx) => { - await tx.query(`INSERT INTO rdf_projection (id, name, version) VALUES - (1, 'VAT Rates', 1), - (2, 'CGT Rates', 1)` - ); - await tx.query(`INSERT INTO rdf_hook (id, projection_id, event) VALUES - (1, 1, 'VAT Rate Changed'), - (2, 2, 'CGT Rate Changed')` - ); - await tx.query(`INSERT INTO rdf_notification (hook_id, projection_id, scheduled_for) VALUES - (1, 1, now())` - ); - }); - - rdf.once('VAT Rate Changed', ({ event, projection }) => { - eq(event, 'VAT Rate Changed') - deq(projection, { name: 'VAT Rates', version: 1 }); - done(); - }) - - rdf.startNotifications(); - }); - - it('should not redeliver successful notifications', async (t, done) => { - await rdf.withTransaction(async (tx) => { - await tx.query(`INSERT INTO rdf_projection (id, name, version) VALUES - (1, 'VAT Rates', 1), - (2, 'CGT Rates', 1)` - ); - await tx.query(`INSERT INTO rdf_hook (id, projection_id, event) VALUES - (1, 1, 'VAT Rate Changed'), - (2, 2, 'CGT Rate Changed')` - ); - await tx.query(`INSERT INTO rdf_notification (hook_id, projection_id, scheduled_for) VALUES - (1, 1, now())` - ); - }); - - rdf.on('VAT Rate Changed', ({ event, projection }) => { - eq(event, 'VAT Rate Changed') - deq(projection, { name: 'VAT Rates', version: 1 }); - setTimeout(done, 1000); - }) - - rdf.startNotifications(); - }); - - it('should redeliver unsuccessful notifications up to the maximum number of attempts', async (t, done) => { - await rdf.withTransaction(async (tx) => { - await tx.query(`INSERT INTO rdf_projection (id, name, version) VALUES - (1, 'VAT Rates', 1), - (2, 'CGT Rates', 1)` - ); - await tx.query(`INSERT INTO rdf_hook (id, projection_id, event) VALUES - (1, 1, 'VAT Rate Changed'), - (2, 2, 'CGT Rate Changed')` - ); - await tx.query(`INSERT INTO rdf_notification (hook_id, projection_id, scheduled_for) VALUES - (1, 1, now())` - ); - }); - - let attempt = 0; - rdf.on('VAT Rate Changed', async () => { - attempt++; - throw new Error('Oh Noes!'); - }); - - setTimeout(async () => { - eq(attempt, 3); - done(); - }, 500) - - rdf.startNotifications(); - }); - - it('should capture the last delivery error', async (t, done) => { - const before = new Date(); - - await rdf.withTransaction(async (tx) => { - await tx.query(`INSERT INTO rdf_projection (id, name, version) VALUES - (1, 'VAT Rates', 1), - (2, 'CGT Rates', 1)` - ); - await tx.query(`INSERT INTO rdf_hook (id, projection_id, event) VALUES - (1, 1, 'VAT Rate Changed'), - (2, 2, 'CGT Rate Changed')` - ); - await tx.query(`INSERT INTO rdf_notification (hook_id, projection_id, scheduled_for) VALUES - (1, 1, now())` - ); - }); - - let attempt = 0; - rdf.on('VAT Rate Changed', () => { - attempt++; - throw new Error(`Oh Noes! ${attempt}`); - }); - - setTimeout(async () => { - const { rows: notifications } = await rdf.withTransaction(async (tx) => { - return tx.query('SELECT * FROM rdf_notification'); - }) - - eq(notifications.length, 1); - eq(notifications[0].status, 'PENDING'); - ok(notifications[0].last_attempted > before); - match(notifications[0].last_error, /Oh Noes! 3/); - done(); - }, 500) - - rdf.startNotifications(); - }); - }) -}); diff --git a/test/migrations/.marvrc b/test/migrations/.marvrc deleted file mode 100644 index 8ea3ed5..0000000 --- a/test/migrations/.marvrc +++ /dev/null @@ -1,6 +0,0 @@ -{ - "filter": "(?:\\.sql|\\.yaml|\\.json)$", - "directives": { - "audit": false - } -} \ No newline at end of file diff --git a/test/migrations/001.define-tax-schema.yaml b/test/migrations/001.define-tax-schema.yaml deleted file mode 100644 index 82c5a39..0000000 --- a/test/migrations/001.define-tax-schema.yaml +++ /dev/null @@ -1,19 +0,0 @@ -define enums: - - name: tax_rate_type - values: - - standard - - reduced - - zero - -define entities: - - name: VAT Rate - version: 1 - fields: - - name: type - type: tax_rate_type - - name: rate - type: NUMERIC - identified by: - - type - checks: - park_code_len: (rate >= 0 AND rate <= 1) diff --git a/test/notifications.test.js b/test/notifications.test.js new file mode 100644 index 0000000..f22effd --- /dev/null +++ b/test/notifications.test.js @@ -0,0 +1,166 @@ +const { ok, strictEqual: eq, deepEqual: deq, rejects, match } = require('node:assert'); +const { describe, it, before, beforeEach, after, afterEach } = require('zunit'); + +const TestReferenceDataFramework = require('./TestReferenceDataFramework'); + +const config = { + migrations: 'test', + database: { + user: 'rdf_test', + password: 'rdf_test' + }, + notifications: { + initialDelay: '0ms', + interval: '100ms', + maxAttempts: 3, + maxRescheduleDelay: '100ms', + }, + nukeCustomObjects: async (tx) => { + await tx.query('DROP TABLE IF EXISTS vat_rate_v1'); + }, + wipeCustomData: async (tx) => { + await tx.query('DELETE FROM vat_rate_v1'); + } +} + +describe('Notifications', () => { + + let rdf; + + before(async () => { + rdf = new TestReferenceDataFramework(config); + await rdf.init(); + await rdf.nukeCustomObjects(); + await rdf.wipeRdfData(); + }) + + beforeEach(async () => { + rdf.removeAllListeners(); + await rdf.nukeCustomObjects(); + await rdf.wipeRdfData(); + }) + + afterEach(async () => { + await rdf.stopNotifications(); + rdf.removeAllListeners(); + }) + + after(async () => { + await rdf.stop(); + }) + + it('should notify interested parties of projection changes', async (t, done) => { + await rdf.withTransaction(async (tx) => { + await tx.query(`INSERT INTO rdf_projection (id, name, version) VALUES + (1, 'VAT Rates', 1), + (2, 'CGT Rates', 1)` + ); + await tx.query(`INSERT INTO rdf_hook (id, projection_id, event) VALUES + (1, 1, 'VAT Rate Changed'), + (2, 2, 'CGT Rate Changed')` + ); + await tx.query(`INSERT INTO rdf_notification (hook_id, projection_id, scheduled_for) VALUES + (1, 1, now())` + ); + }); + + rdf.once('VAT Rate Changed', ({ event, projection }) => { + eq(event, 'VAT Rate Changed') + deq(projection, { name: 'VAT Rates', version: 1 }); + done(); + }) + + rdf.startNotifications(); + }); + + it('should not redeliver successful notifications', async (t, done) => { + await rdf.withTransaction(async (tx) => { + await tx.query(`INSERT INTO rdf_projection (id, name, version) VALUES + (1, 'VAT Rates', 1), + (2, 'CGT Rates', 1)` + ); + await tx.query(`INSERT INTO rdf_hook (id, projection_id, event) VALUES + (1, 1, 'VAT Rate Changed'), + (2, 2, 'CGT Rate Changed')` + ); + await tx.query(`INSERT INTO rdf_notification (hook_id, projection_id, scheduled_for) VALUES + (1, 1, now())` + ); + }); + + rdf.on('VAT Rate Changed', ({ event, projection }) => { + eq(event, 'VAT Rate Changed') + deq(projection, { name: 'VAT Rates', version: 1 }); + setTimeout(done, 1000); + }) + + rdf.startNotifications(); + }); + + it('should redeliver unsuccessful notifications up to the maximum number of attempts', async (t, done) => { + await rdf.withTransaction(async (tx) => { + await tx.query(`INSERT INTO rdf_projection (id, name, version) VALUES + (1, 'VAT Rates', 1), + (2, 'CGT Rates', 1)` + ); + await tx.query(`INSERT INTO rdf_hook (id, projection_id, event) VALUES + (1, 1, 'VAT Rate Changed'), + (2, 2, 'CGT Rate Changed')` + ); + await tx.query(`INSERT INTO rdf_notification (hook_id, projection_id, scheduled_for) VALUES + (1, 1, now())` + ); + }); + + let attempt = 0; + rdf.on('VAT Rate Changed', async () => { + attempt++; + throw new Error('Oh Noes!'); + }); + + setTimeout(async () => { + eq(attempt, 3); + done(); + }, 500) + + rdf.startNotifications(); + }); + + it('should capture the last delivery error', async (t, done) => { + const before = new Date(); + + await rdf.withTransaction(async (tx) => { + await tx.query(`INSERT INTO rdf_projection (id, name, version) VALUES + (1, 'VAT Rates', 1), + (2, 'CGT Rates', 1)` + ); + await tx.query(`INSERT INTO rdf_hook (id, projection_id, event) VALUES + (1, 1, 'VAT Rate Changed'), + (2, 2, 'CGT Rate Changed')` + ); + await tx.query(`INSERT INTO rdf_notification (hook_id, projection_id, scheduled_for) VALUES + (1, 1, now())` + ); + }); + + let attempt = 0; + rdf.on('VAT Rate Changed', () => { + attempt++; + throw new Error(`Oh Noes! ${attempt}`); + }); + + setTimeout(async () => { + const { rows: notifications } = await rdf.withTransaction(async (tx) => { + return tx.query('SELECT * FROM rdf_notification'); + }) + + eq(notifications.length, 1); + eq(notifications[0].status, 'PENDING'); + ok(notifications[0].last_attempted > before); + match(notifications[0].last_error, /Oh Noes! 3/); + done(); + }, 500) + + rdf.startNotifications(); + }); +}); diff --git a/test/schema.test.js b/test/schema.test.js new file mode 100644 index 0000000..4fc7293 --- /dev/null +++ b/test/schema.test.js @@ -0,0 +1,143 @@ +const { ok, strictEqual: eq, deepEqual: deq, rejects, match } = require('node:assert'); +const { describe, it, before, beforeEach, after, afterEach } = require('zunit'); + +const TestReferenceDataFramework = require('./TestReferenceDataFramework'); + +const config = { + migrations: 'test', + database: { + user: 'rdf_test', + password: 'rdf_test' + }, + notifications: { + initialDelay: '0ms', + interval: '100ms', + maxAttempts: 3, + maxRescheduleDelay: '100ms', + } +} + +describe('Schema', () => { + + let rdf; + + before(async () => { + rdf = new TestReferenceDataFramework(config); + await rdf.init(); + await rdf.nukeCustomObjects(); + await rdf.wipeRdfData(); + }) + + beforeEach(async () => { + await rdf.nukeCustomObjects(); + await rdf.wipeRdfData(); + }) + + after(async () => { + await rdf.stop(); + }) + + describe('Projections', () => { + it('should prevent duplicate projections', async () => { + + await rdf.withTransaction(async (tx) => { + await tx.query("INSERT INTO rdf_projection (name, version) VALUES ('NOT DUPLICATE', 1)"); + await tx.query("INSERT INTO rdf_projection (name, version) VALUES ('NOT DUPLICATE', 2)"); + + await tx.query("INSERT INTO rdf_projection (name, version) VALUES ('NOT DUPLICATE A', 1)"); + await tx.query("INSERT INTO rdf_projection (name, version) VALUES ('NOT DUPLICATE B', 1)"); + }); + + await rejects(async () => { + await rdf.withTransaction(async (tx) => { + await tx.query("INSERT INTO rdf_projection (name, version) VALUES ('DUPLICATE', 1)"); + await tx.query("INSERT INTO rdf_projection (name, version) VALUES ('DUPLICATE', 1)"); + }); + }, (err) => { + eq(err.code, '23505'); + return true; + }) + }); + + it('should enforce projections are named', async () => { + await rejects(async () => { + await rdf.withTransaction(async (tx) => { + await tx.query("INSERT INTO rdf_projection (name, version) VALUES (NULL, 1)"); + }); + }, (err) => { + eq(err.code, '23502'); + return true; + }) + }); + + it('should enforce projections are versioned', async () => { + await rejects(async () => { + await rdf.withTransaction(async (tx) => { + await tx.query("INSERT INTO rdf_projection (name, version) VALUES ('OK', NULL)"); + }); + }, (err) => { + eq(err.code, '23502'); + return true; + }) + }); + }); + + describe('Change Sets', () => { + it('should prevent duplicate change sets', async () => { + + await rdf.withTransaction(async (tx) => { + await tx.query(`INSERT INTO rdf_change_set (id, effective_from) VALUES + (1, '2023-01-01T00:00:00.000Z'), + (2, '2023-01-01T00:00:00.000Z') + `); + }); + + await rejects(async () => { + await rdf.withTransaction(async (tx) => { + await tx.query(`INSERT INTO rdf_change_set (id, effective_from) VALUES + (3, '2023-01-01T00:00:00.000Z'), + (3, '2023-01-01T00:00:00.000Z')` + ); + }); + }, (err) => { + eq(err.code, '23505'); + return true; + }) + }); + + it('should enforce change sets have effective_from dates', async () => { + await rejects(async () => { + await rdf.withTransaction(async (tx) => { + await tx.query("INSERT INTO rdf_change_set (id, effective_from) VALUES (1, NULL)"); + }); + }, (err) => { + eq(err.code, '23502'); + return true; + }) + }); + + it('should default last modified date to now', async () => { + const before = new Date(); + + await rdf.withTransaction(async (tx) => { + await tx.query(`INSERT INTO rdf_change_set (id, effective_from, notes) VALUES + (1, '2020-04-05T00:00:00.000Z', 'Countries')` + ); + }); + + const changeSet = await rdf.getChangeSet(1); + ok(changeSet.lastModified >= before); + }); + + it('should default entity tag to random hex', async () => { + await rdf.withTransaction(async (tx) => { + await tx.query(`INSERT INTO rdf_change_set (id, effective_from, notes) VALUES + (1, '2020-04-05T00:00:00.000Z', 'Countries')` + ); + }); + + const changeSet = await rdf.getChangeSet(1); + match(changeSet.entityTag, /^[a-f|0-9]{20}$/); + }); + }); +});