voxgig/seneca-msg-test

View on GitHub
src/msg-test.ts

Summary

Maintainability
F
3 days
Test Coverage
/* Copyright (c) 2018-2024 Voxgig and other contributors, MIT License */
'use strict'

// TODO: add line numbers to all fail msgs!

import Util from 'node:util'
import Assert from 'node:assert'

import Seneca from 'seneca'


const Jsonic = require('jsonic')
const Inks = require('inks')
const Optioner = require('optioner')
const Joi = Optioner.Joi




const optioner = Optioner({
  init: Joi.function(),
  test: Joi.boolean().default(true),
  log: Joi.boolean().default(false),
  data: Joi.object().unknown().default({}),
  context: Joi.object().unknown().default({}),
  fix: Joi.string().default(''), // DEPRECATED, use pattern instead
  pattern: Joi.string().default(''),
  delegates: Joi.object()
    .pattern(/^/, Joi.array().items(Joi.object().allow(null)))
    .default({}),
  allow: Joi.object({
    missing: Joi.boolean().default(false),
  }).default(),
  calls: Joi.alternatives().try(
    Joi.function(),
    Joi.array().items(
      Joi.object({
        name: Joi.string().min(1),
        print: Joi.boolean().default(false),
        print_context: Joi.boolean().default(false),
        pattern: Joi.string().min(3),
        params: Joi.alternatives()
          .try(Joi.object().unknown(), Joi.func())
          .default({}),
        out: Joi.alternatives().try(Joi.object().unknown(), Joi.array()),
        err: Joi.object().unknown(),
        delegate: Joi.alternatives(Joi.string(), Joi.array(), Joi.func()),
        verify: Joi.func(),
        line: Joi.string(),
      })
    )
  ),
})


function msg_test(seneca: any, spec: any) {
  // Seneca instance is optional
  if (seneca && !seneca.seneca) {
    spec = seneca
    seneca = null
  }

  spec = optioner.check(spec)

  Assert('object' === typeof spec.delegates)

  if (null == seneca) {
    seneca = Seneca().test()
  }

  if (spec.init) {
    seneca = spec.init(seneca)
  }

  // top level `pattern` replaces `fix`; `fix` deprecated as does not override
  spec.pattern = '' === spec.pattern ? spec.fix : spec.pattern

  test.run = intern.run
  return test

  async function test() {
    await seneca.ready()

    if (spec.test) {
      seneca.test(null, spec.log ? 'print' : null)
    }

    if (!seneca.has_plugin('promisify')) {
      seneca.use('promisify')
      await seneca.ready()
    }

    var datajson = JSON.stringify(spec.data)

    await seneca.post('role:mem-store,cmd:import', {
      merge: true,
      json: datajson,
      default$: {},
    })

    let calls = Array.isArray(spec.calls) ? spec.calls : spec.calls(LN)

    intern.missing_messages(seneca, spec, calls)

    Object.keys(spec.delegates).forEach((dk) => {
      spec.delegates[dk] = seneca.delegate.apply(seneca, spec.delegates[dk])
    })

    await intern.run(seneca, spec, calls)
  }
}

const intern = (module.exports.intern = {
  run: async function(seneca: any, spec: any, calls: any) {
    let callmap = spec.context

    return new Promise((resolve: any, reject: any) => {
      next_call(0, function(err: any) {
        if (err) {
          return reject(err)
        } else {
          return resolve()
        }
      })
    })

    function next_call(call_index: any, done: any): any {
      try {
        if (calls.length <= call_index) {
          return done()
        }

        var call = calls[call_index]

        if (false === call.run) {
          return setImmediate(next_call.bind(null, call_index + 1, done))
        }

        var params = {}

        if ('function' === typeof call.params) {
          params = call.params(call, callmap, spec, seneca)
        } else {
          params = Inks(call.params, callmap)
        }

        var print = spec.print || call.print

        if (print) {
          console.log('\n\nCALL   : ', call.pattern, params)
        }

        if (call.print_context) {
          console.dir(callmap, { depth: 3, colors: true })
        }

        var msg = Object.assign(
          {},
          params,
          spec.pattern ? Jsonic(spec.pattern) : {},
          Jsonic(call.pattern)
        )
        var msgstr = Jsonic.stringify(msg)
        call.msgstr = msgstr

        let errname = (null == call.name ? '' : call.name + '~') + msgstr

        var instance = intern.handle_delegate(seneca, call, callmap, spec)

        instance.act(msg, function(err: any, out: any, meta: any) {
          // initial call meta data - allows self-refs in validation
          if (call.name) {
            callmap[call.name] = {
              top_pattern: spec.pattern,
              pattern: call.pattern,
              params: params,
              msg: msg,
              err: err,
              out: out,
              meta: meta,
            }
          }

          if (print) {
            console.log('ERROR  : ', err)
            console.log(
              'RESULT : ',
              Util.inspect(out, { depth: null, colors: true })
            )
          }

          if (null == call.err) {
            if (null != err) {
              return done(
                new Error(
                  'Error not expected for: ' + errname + ', err: ' + err
                )
              )
            }
          } else {
            if (null == err) {
              return done(
                new Error('Error expected for: ' + errname + ', was null')
              )
            }

            var result = Optioner(call.err, { must_match_literals: true })(err)
            if (result.error) {
              return done(result.error)
            }
          }

          if (null === call.out) {
            if (null != out) {
              return done(
                new Error(
                  'Output not expected for: ' + errname + ', out: ' + out
                )
              )
            }
          } else if (null != call.out) {
            if (null == out) {
              return done(
                new Error('Output expected for: ' + errname + ', was null')
              )
            } else {
              var current_call_out = Inks(call.out, callmap, {
                exclude: (k: any, v: any) => Joi.isSchema(v, { legacy: true }),
              })

              result = Optioner(current_call_out, {
                must_match_literals: true,
              })(out)
              if (result.error) {
                return done(
                  new Error(
                    'Output for: ' +
                    errname +
                    (call.line ? ' (' + call.line + ')' : '') +
                    ' was invalid: ' +
                    result.error.message
                  )
                )
              }
            }
          }

          if (null != call.verify) {
            call.result = { msg, err, out, meta }

            // TODO: handle Joi validation result
            result = call.verify(call, callmap, spec, instance)
            if (null != result && true !== result) {
              return done(
                new Error(
                  'Verify of: ' +
                  errname +
                  ' failed: ' +
                  (result.message || result)
                )
              )
            }
          }

          if (call.name) {
            callmap[call.name] = {
              top_pattern: spec.pattern,
              pattern: call.pattern,
              params: params,
              msg: msg,
              err: err,
              out: out,
              meta: meta,
            }
          }

          setImmediate(next_call.bind(null, call_index + 1, done))
        })
      } catch (e) {
        return done(e)
      }
    }
  },

  // TODO: support a default delegate
  handle_delegate: function(instance: any, call: any, callmap: any, spec: any) {
    if (call.delegate) {
      if ('string' === typeof call.delegate) {
        instance = spec.delegates[call.delegate]

        if (null == instance) {
          throw new Error(
            'Delegate not defined: ' +
            call.delegate +
            '. Message was: ' +
            call.msgstr
          )
        }
      } else if (Array.isArray(call.delegate)) {
        return instance.delegate.apply(instance, call.delegate)
      } else if ('function' === typeof call.delegate) {
        return call.delegate.call(instance, call, callmap, spec)
      } else {
        throw new Error(
          'Unknown delegate reference: ' +
          Util.inspect(call.delegate) +
          '. Message was: ' +
          call.msgstr
        )
      }
    }

    return instance
  },

  missing_messages: function(seneca: any, spec: any, calls: any) {
    var foundmsgs = seneca
      .list(spec.pattern)
      .map((msg: any) => seneca.util.pattern(msg))

    const specmsgs: any = []

    calls.forEach((call: any) => {
      var specmsg_obj = Jsonic(spec.pattern + ',' + call.pattern)
      specmsgs.push(specmsg_obj)
    })

    // remove msgs once found
    specmsgs.forEach((msg: any) => {
      var found = seneca.find(msg)
      if (found) {
        foundmsgs = foundmsgs.filter((msg: any) => msg != found.pattern)
      }
    })

    // there should be none left - all should be found
    if (0 < foundmsgs.length && !spec.allow.missing) {
      throw new Error('Test calls not defined for: ' + foundmsgs.join('; '))
    }
  },
})

// Get line number of test message in spec file.
// Use as an extra value in msg: `+LN()`
function LN(t: any) {
  var line: any =
    (new Error().stack as any)
      .split('\n')[2]
      .match(/[\/\\]([^./\\]+)[^/\\]*\.js:(\d+):/)
      .filter((_x: any, i: any) => i == 1 || i == 2)
      .join('~')

  if (null == t) {
    return ',LN:' + line
  } else {
    t.line = line
    return t
  }
}


msg_test.MsgTest = msg_test
msg_test.Joi = Joi
msg_test.LN = LN


export default msg_test


if ('undefined' !== typeof module) {
  module.exports = msg_test
}