From 10f3094a4d4b37ac6c2223de6b6797ab4fa3f0ca Mon Sep 17 00:00:00 2001 From: Clint Zirker Date: Thu, 22 Jan 2026 19:12:02 -0700 Subject: [PATCH] Add unit tests and enable --all coverage flag - Update .nycrc.json with all: true for comprehensive coverage - Add lib.aws-util.utest.ts (20 tests) - Add lib.DynamodbFetcher.utest.ts (DynamoDB fetcher tests) - Add lib.parser-util.utest.ts (parser utility tests) Coverage: 23.16% -> 25.75% (310 tests) --- .nycrc.json | 9 +- test/lib.DynamodbFetcher.utest.ts | 174 +++++++++++++++++ test/lib.aws-util.utest.ts | 145 ++++++++++++++ test/lib.parser-util.utest.ts | 306 ++++++++++++++++++++++++++++++ 4 files changed, 632 insertions(+), 2 deletions(-) create mode 100644 test/lib.DynamodbFetcher.utest.ts create mode 100644 test/lib.aws-util.utest.ts create mode 100644 test/lib.parser-util.utest.ts diff --git a/.nycrc.json b/.nycrc.json index d3575271..c7d7969d 100644 --- a/.nycrc.json +++ b/.nycrc.json @@ -5,7 +5,12 @@ "statements": 80, "functions": 80, "branches": 80, - "include": [], + "include": [ + "lib/**/*.ts", + "lib/**/*.js", + "wrappers/**/*.js", + "index.js" + ], "exclude": [ "**/*.utest.js", "**/*.d.ts", @@ -38,5 +43,5 @@ ".tsx" ], "cache": true, - "all": false + "all": true } diff --git a/test/lib.DynamodbFetcher.utest.ts b/test/lib.DynamodbFetcher.utest.ts new file mode 100644 index 00000000..9b42d67f --- /dev/null +++ b/test/lib.DynamodbFetcher.utest.ts @@ -0,0 +1,174 @@ +import { expect, assert } from "chai"; +import sinon from "sinon"; +import { DynamodbFetcher } from "../lib/DynamodbFetcher"; + +describe('lib/DynamodbFetcher.ts', function () { + let fetcher: DynamodbFetcher; + + beforeEach(function () { + fetcher = new DynamodbFetcher('test-table', (item) => ({ id: item.id })); + }); + + describe("constructor", function () { + it('should set tableName', function () { + expect(fetcher.tableName).to.equal('test-table'); + }); + + it('should set getDdbKey function', function () { + const item = { id: '123', name: 'test' }; + const key = fetcher.getDdbKey(item); + expect(key).to.deep.equal({ id: '123' }); + }); + + it('should initialize batch counter to 0', function () { + expect(fetcher.batch).to.equal(0); + }); + + it('should set batchOptions if provided', function () { + const options = { maxRecords: 100, parallelLimit: 10 }; + const fetcherWithOptions = new DynamodbFetcher('table', (i) => i, options as any); + expect(fetcherWithOptions.batchOptions).to.not.be.undefined; + }); + + it('should create ddb client', function () { + expect(fetcher.ddb).to.not.be.undefined; + }); + }); + + describe("keyToString", function () { + it('should convert single key to string', function () { + const key = { id: '123' }; + const result = fetcher.keyToString(key); + expect(result).to.equal('id:123'); + }); + + it('should convert multiple keys to string sorted alphabetically', function () { + const key = { z: '3', a: '1', m: '2' }; + const result = fetcher.keyToString(key); + expect(result).to.equal('a:1/m:2/z:3'); + }); + + it('should handle numeric values', function () { + const key = { id: 123 }; + const result = fetcher.keyToString(key); + expect(result).to.equal('id:123'); + }); + + it('should handle composite keys', function () { + const key = { pk: 'user#123', sk: 'profile' }; + const result = fetcher.keyToString(key); + expect(result).to.equal('pk:user#123/sk:profile'); + }); + + it('should handle empty object', function () { + const key = {}; + const result = fetcher.keyToString(key); + expect(result).to.equal(''); + }); + }); + + describe("join", function () { + let batchGetStub: sinon.SinonStub; + + beforeEach(function () { + batchGetStub = sinon.stub(fetcher.ddb, 'batchGet'); + }); + + afterEach(function () { + batchGetStub.restore(); + }); + + it('should return events with joinedData when DDB returns data', async function () { + batchGetStub.resolves({ + Responses: { + 'test-table': [ + { id: '1', name: 'Item 1' }, + { id: '2', name: 'Item 2' } + ] + } + }); + + const events = [ + { id: '1', value: 'a' }, + { id: '2', value: 'b' } + ]; + + const result = await fetcher.join(events); + + expect(result).to.have.length(2); + expect((result[0] as any).joinedData).to.deep.equal({ id: '1', name: 'Item 1' }); + expect((result[1] as any).joinedData).to.deep.equal({ id: '2', name: 'Item 2' }); + }); + + it('should handle empty events array', async function () { + const result = await fetcher.join([]); + expect(result).to.deep.equal([]); + expect(batchGetStub.called).to.be.false; + }); + + it('should handle events with null keys', async function () { + const nullKeyFetcher = new DynamodbFetcher('test-table', (item) => item.id ? { id: item.id } : null); + const batchGetStubNull = sinon.stub(nullKeyFetcher.ddb, 'batchGet'); + batchGetStubNull.resolves({ + Responses: { + 'test-table': [{ id: '1', name: 'Item 1' }] + } + }); + + const events = [ + { id: '1', value: 'a' }, + { value: 'b' } // no id, key will be null + ]; + + const result = await nullKeyFetcher.join(events); + + expect(result).to.have.length(2); + expect((result[0] as any).joinedData).to.not.be.undefined; + expect((result[1] as any).joinedData).to.be.undefined; + + batchGetStubNull.restore(); + }); + + it('should deduplicate keys', async function () { + batchGetStub.resolves({ + Responses: { + 'test-table': [{ id: '1', name: 'Item 1' }] + } + }); + + const events = [ + { id: '1', value: 'a' }, + { id: '1', value: 'b' } // same id + ]; + + await fetcher.join(events); + + // Should only have one key in the batch + const batchParams = batchGetStub.firstCall.args[0]; + expect(batchParams.RequestItems['test-table'].Keys).to.have.length(1); + }); + + it('should increment batch counter', async function () { + batchGetStub.resolves({ Responses: { 'test-table': [] } }); + + expect(fetcher.batch).to.equal(0); + await fetcher.join([{ id: '1' }]); + expect(fetcher.batch).to.equal(1); + await fetcher.join([{ id: '2' }]); + expect(fetcher.batch).to.equal(2); + }); + + it('should handle DDB returning undefined joinedData', async function () { + batchGetStub.resolves({ + Responses: { + 'test-table': [] // no matching items + } + }); + + const events = [{ id: '1', value: 'a' }]; + const result = await fetcher.join(events); + + expect((result[0] as any).joinedData).to.be.undefined; + }); + }); +}); diff --git a/test/lib.aws-util.utest.ts b/test/lib.aws-util.utest.ts new file mode 100644 index 00000000..ecdf8a63 --- /dev/null +++ b/test/lib.aws-util.utest.ts @@ -0,0 +1,145 @@ +import { expect, assert } from "chai"; +import { copy, error, date } from "../lib/aws-util"; + +describe('lib/aws-util.ts', function () { + + describe("copy", function () { + it('should return null for null input', function () { + assert.equal(copy(null), null); + }); + + it('should return undefined for undefined input', function () { + assert.equal(copy(undefined), undefined); + }); + + it('should create a shallow copy of an object', function () { + const original = { a: 1, b: 2 }; + const copied = copy(original); + expect(copied).to.deep.equal(original); + expect(copied).to.not.equal(original); + }); + + it('should copy all properties', function () { + const original = { name: 'test', value: 42, flag: true }; + const copied = copy(original); + expect(copied.name).to.equal('test'); + expect(copied.value).to.equal(42); + expect(copied.flag).to.equal(true); + }); + + it('should handle empty object', function () { + const original = {}; + const copied = copy(original); + expect(copied).to.deep.equal({}); + expect(copied).to.not.equal(original); + }); + }); + + describe("error", function () { + it('should set message from string options', function () { + const err: any = new Error(); + const result = error(err, 'test message'); + expect(result.message).to.equal('test message'); + }); + + it('should set message from object options', function () { + const err: any = new Error(); + const result = error(err, { message: 'object message' }); + expect(result.message).to.equal('object message'); + }); + + it('should set code from options', function () { + const err: any = new Error(); + const result = error(err, { code: 'TEST_CODE' }); + expect(result.code).to.equal('TEST_CODE'); + }); + + it('should set name from options.name', function () { + const err: any = new Error(); + const result = error(err, { name: 'CustomError' }); + expect(result.name).to.equal('CustomError'); + }); + + it('should use code as name if name not provided', function () { + const err: any = new Error(); + const result = error(err, { code: 'ERROR_CODE' }); + expect(result.name).to.equal('ERROR_CODE'); + }); + + it('should set stack from options', function () { + const err: any = new Error(); + const customStack = 'custom stack trace'; + const result = error(err, { stack: customStack }); + expect(result.stack).to.equal(customStack); + }); + + it('should set time to current date', function () { + const err: any = new Error(); + const before = new Date(); + const result = error(err, 'test'); + const after = new Date(); + expect(result.time.getTime()).to.be.at.least(before.getTime()); + expect(result.time.getTime()).to.be.at.most(after.getTime()); + }); + + it('should preserve original error when overwriting message', function () { + const err: any = new Error('original message'); + const result = error(err, 'new message'); + expect(result.originalError).to.not.be.undefined; + expect(result.originalError.message).to.equal('original message'); + }); + + it('should preserve original error with object options containing message', function () { + const err: any = new Error('original message'); + const result = error(err, { message: 'new message' }); + expect(result.originalError).to.not.be.undefined; + expect(result.originalError.message).to.equal('original message'); + }); + + it('should handle null options', function () { + const err: any = new Error('test'); + const result = error(err, null); + expect(result.message).to.equal('test'); + }); + + it('should handle error with empty message', function () { + const err: any = new Error(); + err.message = ''; + const result = error(err, { code: 'TEST' }); + expect(result.message).to.equal(null); + }); + + it('should default name to Error when no options provided', function () { + const err: any = {}; + const result = error(err, null); + expect(result.name).to.equal('Error'); + }); + + it('should update err with all options properties', function () { + const err: any = new Error(); + const result = error(err, { + message: 'test', + code: 'CODE', + customProp: 'custom' + }); + expect(result.customProp).to.equal('custom'); + }); + }); + + describe("date", function () { + describe("getDate", function () { + it('should return a Date object', function () { + const result = date.getDate(); + expect(result).to.be.instanceOf(Date); + }); + + it('should return current time', function () { + const before = Date.now(); + const result = date.getDate(); + const after = Date.now(); + expect(result.getTime()).to.be.at.least(before); + expect(result.getTime()).to.be.at.most(after); + }); + }); + }); +}); diff --git a/test/lib.parser-util.utest.ts b/test/lib.parser-util.utest.ts new file mode 100644 index 00000000..c348ea67 --- /dev/null +++ b/test/lib.parser-util.utest.ts @@ -0,0 +1,306 @@ +import { expect, assert } from "chai"; +import { + FastParseType, + fieldParsers, + parsers, + ParserName, + createParser, + FastJsonPlus +} from "../lib/stream/helper/parser-util"; + +describe('lib/stream/helper/parser-util.ts', function () { + + describe("FastParseType enum", function () { + it('should have Number type', function () { + expect(FastParseType.Number).to.equal('number'); + }); + + it('should have String type', function () { + expect(FastParseType.String).to.equal('string'); + }); + + it('should have IfString type', function () { + expect(FastParseType.IfString).to.equal('ifstring'); + }); + + it('should have Eid type', function () { + expect(FastParseType.Eid).to.equal('eid'); + }); + + it('should have Raw type', function () { + expect(FastParseType.Raw).to.equal('raw'); + }); + + it('should have Json type', function () { + expect(FastParseType.Json).to.equal('json'); + }); + + it('should have Array type', function () { + expect(FastParseType.Array).to.equal('array'); + }); + + it('should have Object type', function () { + expect(FastParseType.Object).to.equal('object'); + }); + }); + + describe("ParserName enum", function () { + it('should have JsonParse', function () { + expect(ParserName.JsonParse).to.equal('JSON.parse'); + }); + + it('should have Empty', function () { + expect(ParserName.Empty).to.equal('empty'); + }); + + it('should have FastJson', function () { + expect(ParserName.FastJson).to.equal('fast-json'); + }); + }); + + describe("fieldParsers", function () { + describe("String parser", function () { + it('should return value as-is', function () { + const result = fieldParsers[FastParseType.String].parse!('test'); + expect(result).to.equal('test'); + }); + }); + + describe("Number parser", function () { + it('should convert string to number', function () { + const result = fieldParsers[FastParseType.Number].parse!('42'); + expect(result).to.equal(42); + }); + + it('should handle decimal numbers', function () { + const result = fieldParsers[FastParseType.Number].parse!('3.14'); + expect(result).to.equal(3.14); + }); + + it('should return NaN for invalid numbers', function () { + const result = fieldParsers[FastParseType.Number].parse!('not a number'); + expect(result).to.be.NaN; + }); + }); + + describe("Eid parser", function () { + it('should parse integer eid', function () { + const result = fieldParsers[FastParseType.Eid].parse!('12345'); + expect(result).to.equal(12345); + }); + + it('should keep z/ prefixed eids as strings', function () { + const result = fieldParsers[FastParseType.Eid].parse!('z/2024/01/01/12/00'); + expect(result).to.equal('z/2024/01/01/12/00'); + }); + }); + + describe("Json parser", function () { + it('should parse JSON string', function () { + const result = fieldParsers[FastParseType.Json].parse!('{"key":"value"}'); + expect(result).to.deep.equal({ key: 'value' }); + }); + + it('should parse JSON array', function () { + const result = fieldParsers[FastParseType.Json].parse!('[1,2,3]'); + expect(result).to.deep.equal([1, 2, 3]); + }); + }); + + describe("Array parser", function () { + it('should parse JSON array', function () { + const result = fieldParsers[FastParseType.Array].parse!('[1,"two",3]'); + expect(result).to.deep.equal([1, 'two', 3]); + }); + }); + + describe("Object parser", function () { + it('should parse JSON object', function () { + const result = fieldParsers[FastParseType.Object].parse!('{"nested":{"a":1}}'); + expect(result).to.deep.equal({ nested: { a: 1 } }); + }); + }); + + describe("IfString parser", function () { + it('should call setFn for actual strings', function () { + let setCalled = false; + let setArgs: any[] = []; + const setFn = (field, value) => { + setCalled = true; + setArgs = [field, value]; + }; + fieldParsers[FastParseType.IfString].set!('field', 'actual string', setFn); + expect(setCalled).to.be.true; + expect(setArgs).to.deep.equal(['field', 'actual string']); + }); + + it('should not call setFn for JSON-like strings starting with {', function () { + let setCalled = false; + const setFn = () => { setCalled = true; }; + fieldParsers[FastParseType.IfString].set!('field', '{"key":"value"}', setFn); + expect(setCalled).to.be.false; + }); + + it('should not call setFn for JSON-like strings starting with [', function () { + let setCalled = false; + const setFn = () => { setCalled = true; }; + fieldParsers[FastParseType.IfString].set!('field', '[1,2,3]', setFn); + expect(setCalled).to.be.false; + }); + }); + + describe("Raw parser", function () { + it('should call setFn with _raw suffix', function () { + let setArgs: any[] = []; + const setFn = (field, value, suffix) => { + setArgs = [field, value, suffix]; + }; + fieldParsers[FastParseType.Raw].set!('field', 'value', setFn); + expect(setArgs).to.deep.equal(['field', 'value', '_raw']); + }); + }); + }); + + describe("parsers", function () { + describe("JSON.parse parser", function () { + it('should return JSON.parse function', function () { + const parser = parsers[ParserName.JsonParse]({}); + const result = parser('{"test":true}'); + expect(result).to.deep.equal({ test: true }); + }); + }); + + describe("empty parser", function () { + it('should return default object with id and payload', function () { + const parser = parsers[ParserName.Empty]({}); + const result = parser('anything'); + expect(result).to.deep.equal({ id: 'unknown', payload: {} }); + }); + }); + + describe("fast-json parser", function () { + it('should parse JSON with __unparsed_value__', function () { + const parser = parsers[ParserName.FastJson]({ fields: [] }); + const result = parser('{"id":"test"}'); + expect(result.__unparsed_value__).to.equal('{"id":"test"}'); + }); + + it('should extract id field', function () { + const parser = parsers[ParserName.FastJson]({ fields: [] }); + const result = parser('{"id":"my-id","event":"test"}'); + expect(result.id).to.equal('my-id'); + }); + + it('should extract event field', function () { + const parser = parsers[ParserName.FastJson]({ fields: [] }); + const result = parser('{"id":"1","event":"my-event"}'); + expect(result.event).to.equal('my-event'); + }); + + it('should extract event_source_timestamp as number', function () { + const parser = parsers[ParserName.FastJson]({ fields: [] }); + const result = parser('{"id":"1","event_source_timestamp":1234567890}'); + expect(result.event_source_timestamp).to.equal(1234567890); + }); + + it('should extract custom fields', function () { + const parser = parsers[ParserName.FastJson]({ + fields: [{ field: 'payload.name' }] + }); + const result = parser('{"id":"1","payload":{"name":"test"}}'); + expect(result.payload.name).to.equal('test'); + }); + + it('should extract custom fields with number type', function () { + const parser = parsers[ParserName.FastJson]({ + fields: [{ field: 'payload.count', type: FastParseType.Number }] + }); + const result = parser('{"id":"1","payload":{"count":"42"}}'); + expect(result.payload.count).to.equal(42); + }); + }); + }); + + describe("createParser", function () { + it('should create parser using JSON.parse', function () { + const parser = createParser({ + parser: ParserName.JsonParse, + opts: {} + }); + const result = parser('{"test":"value"}'); + expect(result.test).to.equal('value'); + }); + + it('should handle eid commands', function () { + const parser = createParser({ + parser: ParserName.JsonParse, + opts: {} + }); + const result = parser('__cmd:eid__{"eid":"z/2024/01/01"}'); + expect(result._cmd).to.equal('setBaseEid'); + expect(result.eid).to.equal('z/2024/01/01'); + }); + + it('should add size property to result', function () { + const parser = createParser({ + parser: ParserName.JsonParse, + opts: {} + }); + const input = '{"id":"test"}'; + const result = parser(input); + expect(result.size).to.equal(Buffer.byteLength(input)); + }); + + it('should not override existing size property', function () { + const parser = createParser({ + parser: ParserName.JsonParse, + opts: {} + }); + const result = parser('{"id":"test","size":100}'); + expect(result.size).to.equal(100); + }); + + it('should use custom parser function', function () { + const customParser = (str: string) => ({ custom: true, data: str }); + const parser = createParser({ + parser: customParser, + opts: {} + }); + const result = parser('test input'); + expect(result.custom).to.be.true; + }); + + it('should create empty parser', function () { + const parser = createParser({ + parser: ParserName.Empty, + opts: {} + }); + const result = parser('anything'); + expect(result.id).to.equal('unknown'); + expect(result.payload).to.deep.equal({}); + }); + + it('should create fast-json parser with opts', function () { + const parser = createParser({ + parser: ParserName.FastJson, + opts: { fields: [] } + }); + const result = parser('{"id":"test","event":"test-event"}'); + expect(result.id).to.equal('test'); + }); + }); + + describe("FastJsonPlus", function () { + it('should parse JSON and store in __unparsed_value__', function () { + const parser = new FastJsonPlus(); + const input = '{"test":"value"}'; + const result = parser.parse(input); + expect(result.__unparsed_value__).to.equal(input); + }); + + it('should have isLastPrimitiveAString property', function () { + const parser = new FastJsonPlus(); + expect(parser.isLastPrimitiveAString).to.be.false; + }); + }); +});