diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..8a979af --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,15 @@ +name: Tests +on: push +jobs: + default: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 'lts/*' + cache: 'npm' + - run: npm ci + - run: npm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c45938 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +*.log +.DS_Store diff --git a/package.json b/package.json index 5657b6d..b41f16f 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,9 @@ "type": "module", "main": "index.js", "repository": "github:adapt-security/adapt-authoring-errors", + "scripts": { + "test": "node --test 'tests/**/*.spec.js'" + }, "dependencies": { "glob": "^13.0.0" }, diff --git a/tests/AdaptError.spec.js b/tests/AdaptError.spec.js new file mode 100644 index 0000000..70c549c --- /dev/null +++ b/tests/AdaptError.spec.js @@ -0,0 +1,222 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import AdaptError from '../lib/AdaptError.js' + +describe('AdaptError', () => { + describe('constructor', () => { + it('should create an error with code only', () => { + const error = new AdaptError('TEST_ERROR') + assert.equal(error.code, 'TEST_ERROR') + assert.equal(error.statusCode, 500) + assert.deepEqual(error.meta, {}) + assert.ok(error instanceof Error) + assert.ok(error instanceof AdaptError) + }) + + it('should create an error with code and statusCode', () => { + const error = new AdaptError('NOT_FOUND', 404) + assert.equal(error.code, 'NOT_FOUND') + assert.equal(error.statusCode, 404) + assert.deepEqual(error.meta, {}) + }) + + it('should create an error with code, statusCode, and metadata', () => { + const metadata = { description: 'Test error', data: { id: '123' } } + const error = new AdaptError('CUSTOM_ERROR', 400, metadata) + assert.equal(error.code, 'CUSTOM_ERROR') + assert.equal(error.statusCode, 400) + assert.deepEqual(error.meta, metadata) + }) + + it('should use default statusCode of 500 when not provided', () => { + const error = new AdaptError('SERVER_ERROR') + assert.equal(error.statusCode, 500) + }) + + it('should use empty object as default metadata when not provided', () => { + const error = new AdaptError('TEST_ERROR', 400) + assert.deepEqual(error.meta, {}) + }) + + it('should set the message property to the error code', () => { + const error = new AdaptError('MY_ERROR') + assert.equal(error.message, 'MY_ERROR') + }) + }) + + describe('#setData()', () => { + it('should set data on the error', () => { + const error = new AdaptError('TEST_ERROR') + const data = { userId: '123', action: 'delete' } + error.setData(data) + assert.deepEqual(error.data, data) + }) + + it('should return the error instance for chaining', () => { + const error = new AdaptError('TEST_ERROR') + const returnValue = error.setData({ test: 'value' }) + assert.equal(returnValue, error) + }) + + it('should allow method chaining', () => { + const error = new AdaptError('TEST_ERROR') + const data = { key: 'value' } + const result = error.setData(data) + assert.equal(result.data, data) + assert.ok(result instanceof AdaptError) + }) + + it('should overwrite existing data', () => { + const error = new AdaptError('TEST_ERROR') + error.setData({ first: 'data' }) + error.setData({ second: 'data' }) + assert.deepEqual(error.data, { second: 'data' }) + }) + }) + + describe('#toString()', () => { + it('should return formatted string without data', () => { + const error = new AdaptError('TEST_ERROR') + const result = error.toString() + // Note: trailing space after code is part of the current implementation + assert.equal(result, 'AdaptError: TEST_ERROR ') + }) + + it('should return formatted string with data', () => { + const error = new AdaptError('TEST_ERROR') + error.setData({ userId: '123' }) + const result = error.toString() + assert.equal(result, 'AdaptError: TEST_ERROR {"userId":"123"}') + }) + + it('should include class name in output', () => { + const error = new AdaptError('MY_ERROR') + const result = error.toString() + assert.ok(result.startsWith('AdaptError:')) + }) + + it('should handle complex data objects', () => { + const error = new AdaptError('COMPLEX_ERROR') + const complexData = { nested: { key: 'value' }, array: [1, 2, 3] } + error.setData(complexData) + const result = error.toString() + assert.ok(result.includes(JSON.stringify(complexData))) + }) + }) + + describe('Error properties', () => { + it('should have code property', () => { + const error = new AdaptError('TEST_CODE') + assert.equal(typeof error.code, 'string') + assert.equal(error.code, 'TEST_CODE') + }) + + it('should have statusCode property', () => { + const error = new AdaptError('TEST_ERROR', 404) + assert.equal(typeof error.statusCode, 'number') + assert.equal(error.statusCode, 404) + }) + + it('should have meta property', () => { + const meta = { description: 'Test' } + const error = new AdaptError('TEST_ERROR', 500, meta) + assert.equal(typeof error.meta, 'object') + assert.deepEqual(error.meta, meta) + }) + + it('should have data property after setData is called', () => { + const error = new AdaptError('TEST_ERROR') + error.setData({ test: 'data' }) + assert.equal(typeof error.data, 'object') + assert.deepEqual(error.data, { test: 'data' }) + }) + }) + + describe('Error inheritance', () => { + it('should have a stack trace', () => { + const error = new AdaptError('TEST_ERROR') + assert.equal(typeof error.stack, 'string') + assert.ok(error.stack.length > 0) + }) + + it('should be catchable as a generic Error', () => { + let caught = false + try { + throw new AdaptError('THROWN_ERROR', 400) + } catch (e) { + caught = true + assert.ok(e instanceof Error) + assert.equal(e.code, 'THROWN_ERROR') + assert.equal(e.statusCode, 400) + } + assert.ok(caught) + }) + + it('should inherit name from Error', () => { + const error = new AdaptError('TEST_ERROR') + assert.equal(error.name, 'Error') + }) + }) + + describe('setData and throw pattern', () => { + it('should support throw with chained setData', () => { + const data = { userId: '456' } + let caught + try { + throw new AdaptError('AUTH_ERROR', 401).setData(data) + } catch (e) { + caught = e + } + assert.ok(caught instanceof AdaptError) + assert.deepEqual(caught.data, data) + assert.equal(caught.code, 'AUTH_ERROR') + }) + }) + + describe('Edge cases', () => { + it('should handle empty string as error code', () => { + const error = new AdaptError('') + assert.equal(error.code, '') + assert.equal(error.message, '') + }) + + it('should handle null metadata', () => { + const error = new AdaptError('TEST_ERROR', 500, null) + assert.equal(error.meta, null) + }) + + it('should handle zero statusCode', () => { + const error = new AdaptError('TEST_ERROR', 0) + assert.equal(error.statusCode, 0) + }) + + it('should handle setData with null', () => { + const error = new AdaptError('TEST_ERROR') + error.setData(null) + assert.equal(error.data, null) + }) + + it('should handle setData with undefined', () => { + const error = new AdaptError('TEST_ERROR') + error.setData(undefined) + assert.equal(error.data, undefined) + }) + + it('should handle setData with a string value', () => { + const error = new AdaptError('TEST_ERROR') + error.setData('simple string') + assert.equal(error.data, 'simple string') + }) + + it('should handle toString with empty data object', () => { + const error = new AdaptError('TEST_ERROR') + error.setData({}) + assert.equal(error.toString(), 'AdaptError: TEST_ERROR {}') + }) + + it('should not have data property before setData is called', () => { + const error = new AdaptError('TEST_ERROR') + assert.equal(error.data, undefined) + }) + }) +}) diff --git a/tests/ErrorsModule.spec.js b/tests/ErrorsModule.spec.js new file mode 100644 index 0000000..8a75d85 --- /dev/null +++ b/tests/ErrorsModule.spec.js @@ -0,0 +1,424 @@ +import { describe, it, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import AdaptError from '../lib/AdaptError.js' +import { readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +// Note: ErrorsModule depends on adapt-authoring-core which is not available, +// so we test the core logic that can be tested independently +describe('ErrorsModule', () => { + let tempDir + + beforeEach(() => { + // Create a temporary directory for test errors + tempDir = join(__dirname, 'temp-test-errors') + mkdirSync(tempDir, { recursive: true }) + mkdirSync(join(tempDir, 'errors'), { recursive: true }) + }) + + afterEach(() => { + // Cleanup temp directory + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }) + } + }) + + describe('Error definition structure', () => { + it('should validate error definition format', () => { + const errorDef = { + description: 'Test error description', + statusCode: 404, + data: { + id: 'Item identifier' + } + } + + assert.ok(errorDef.description) + assert.equal(typeof errorDef.description, 'string') + assert.ok(errorDef.statusCode) + assert.equal(typeof errorDef.statusCode, 'number') + if (errorDef.data) { + assert.equal(typeof errorDef.data, 'object') + } + }) + + it('should create AdaptError from definition', () => { + const errorDef = { + description: 'Test error', + statusCode: 500, + data: { + field: 'Test field' + } + } + + const metadata = { description: errorDef.description } + if (errorDef.data) { + metadata.data = errorDef.data + } + + const error = new AdaptError('TEST_CODE', errorDef.statusCode, metadata) + + assert.ok(error instanceof AdaptError) + assert.equal(error.code, 'TEST_CODE') + assert.equal(error.statusCode, 500) + assert.equal(error.meta.description, 'Test error') + assert.deepEqual(error.meta.data, { field: 'Test field' }) + }) + + it('should handle error definition without data field', () => { + const errorDef = { + description: 'Simple error', + statusCode: 400 + } + + const metadata = { description: errorDef.description } + const error = new AdaptError('SIMPLE_ERROR', errorDef.statusCode, metadata) + + assert.ok(error.meta.description) + assert.equal(error.meta.data, undefined) + }) + }) + + describe('Error JSON file format', () => { + it('should parse valid error JSON file', () => { + const errorDefs = { + ERROR_ONE: { + description: 'First error', + statusCode: 400 + }, + ERROR_TWO: { + description: 'Second error', + statusCode: 404, + data: { + id: 'Identifier' + } + } + } + + writeFileSync( + join(tempDir, 'errors', 'test.json'), + JSON.stringify(errorDefs) + ) + + const content = JSON.parse( + readFileSync(join(tempDir, 'errors', 'test.json'), 'utf8') + ) + + assert.ok(content.ERROR_ONE) + assert.ok(content.ERROR_TWO) + assert.equal(content.ERROR_ONE.statusCode, 400) + assert.equal(content.ERROR_TWO.statusCode, 404) + }) + + it('should validate error codes are uppercase with underscores', () => { + const validCodes = ['TEST_ERROR', 'NOT_FOUND', 'SERVER_ERROR', 'MY_CUSTOM_ERROR'] + + validCodes.forEach(code => { + assert.ok(/^[A-Z_]+$/.test(code), `${code} should be uppercase with underscores`) + }) + }) + + it('should handle multiple error JSON files', () => { + writeFileSync( + join(tempDir, 'errors', 'set-a.json'), + JSON.stringify({ ERR_A: { description: 'Error A', statusCode: 400 } }) + ) + writeFileSync( + join(tempDir, 'errors', 'set-b.json'), + JSON.stringify({ ERR_B: { description: 'Error B', statusCode: 404 } }) + ) + + const contentA = JSON.parse( + readFileSync(join(tempDir, 'errors', 'set-a.json'), 'utf8') + ) + const contentB = JSON.parse( + readFileSync(join(tempDir, 'errors', 'set-b.json'), 'utf8') + ) + + const merged = { ...contentA, ...contentB } + assert.ok(merged.ERR_A) + assert.ok(merged.ERR_B) + assert.equal(Object.keys(merged).length, 2) + }) + }) + + describe('Error definition examples', () => { + it('should demonstrate typical error patterns', () => { + const errorPatterns = [ + { code: 'NOT_FOUND', statusCode: 404, description: 'Resource not found' }, + { code: 'UNAUTHORIZED', statusCode: 401, description: 'Authentication required' }, + { code: 'FORBIDDEN', statusCode: 403, description: 'Access denied' }, + { code: 'BAD_REQUEST', statusCode: 400, description: 'Invalid request' }, + { code: 'SERVER_ERROR', statusCode: 500, description: 'Internal server error' } + ] + + errorPatterns.forEach(pattern => { + const error = new AdaptError(pattern.code, pattern.statusCode, { description: pattern.description }) + assert.equal(error.code, pattern.code) + assert.equal(error.statusCode, pattern.statusCode) + assert.equal(error.meta.description, pattern.description) + }) + }) + }) + + describe('Error sorting', () => { + it('should sort error codes alphabetically', () => { + const unsortedCodes = ['ZEBRA_ERROR', 'ALPHA_ERROR', 'MIDDLE_ERROR', 'BETA_ERROR'] + const sortedCodes = [...unsortedCodes].sort() + + assert.deepEqual(sortedCodes, ['ALPHA_ERROR', 'BETA_ERROR', 'MIDDLE_ERROR', 'ZEBRA_ERROR']) + }) + }) + + describe('Error metadata handling', () => { + it('should preserve description in metadata', () => { + const description = 'A detailed error description' + const metadata = { description } + const error = new AdaptError('TEST', 500, metadata) + + assert.equal(error.meta.description, description) + }) + + it('should preserve data schema in metadata', () => { + const data = { + userId: 'User identifier', + action: 'The action being performed' + } + const metadata = { description: 'Test', data } + const error = new AdaptError('TEST', 500, metadata) + + assert.deepEqual(error.meta.data, data) + }) + }) + + describe('loadErrors reduction logic', () => { + it('should create getter properties that return AdaptError instances', () => { + const errorDefs = { + TEST_ERR: { description: 'A test', statusCode: 500 } + } + + const result = Object.entries(errorDefs) + .sort() + .reduce((m, [k, { description, statusCode, data }]) => { + return Object.defineProperty(m, k, { + get: () => { + const metadata = { description } + if (data) metadata.data = data + return new AdaptError(k, statusCode, metadata) + }, + enumerable: true + }) + }, {}) + + const error = result.TEST_ERR + assert.ok(error instanceof AdaptError) + assert.equal(error.code, 'TEST_ERR') + assert.equal(error.statusCode, 500) + assert.equal(error.meta.description, 'A test') + }) + + it('should return a new instance on each property access', () => { + const errorDefs = { + MY_ERROR: { description: 'Repeated', statusCode: 400 } + } + + const result = Object.entries(errorDefs) + .sort() + .reduce((m, [k, { description, statusCode, data }]) => { + return Object.defineProperty(m, k, { + get: () => { + const metadata = { description } + if (data) metadata.data = data + return new AdaptError(k, statusCode, metadata) + }, + enumerable: true + }) + }, {}) + + const first = result.MY_ERROR + const second = result.MY_ERROR + assert.notEqual(first, second) + assert.equal(first.code, second.code) + }) + + it('should include data in metadata when defined', () => { + const errorDefs = { + DATA_ERR: { description: 'Has data', statusCode: 404, data: { id: 'Item ID' } } + } + + const result = Object.entries(errorDefs) + .sort() + .reduce((m, [k, { description, statusCode, data }]) => { + return Object.defineProperty(m, k, { + get: () => { + const metadata = { description } + if (data) metadata.data = data + return new AdaptError(k, statusCode, metadata) + }, + enumerable: true + }) + }, {}) + + const error = result.DATA_ERR + assert.deepEqual(error.meta.data, { id: 'Item ID' }) + }) + + it('should omit data from metadata when not defined', () => { + const errorDefs = { + NO_DATA_ERR: { description: 'No data', statusCode: 500 } + } + + const result = Object.entries(errorDefs) + .sort() + .reduce((m, [k, { description, statusCode, data }]) => { + return Object.defineProperty(m, k, { + get: () => { + const metadata = { description } + if (data) metadata.data = data + return new AdaptError(k, statusCode, metadata) + }, + enumerable: true + }) + }, {}) + + const error = result.NO_DATA_ERR + assert.equal(error.meta.data, undefined) + assert.ok(!('data' in error.meta)) + }) + + it('should sort error codes alphabetically in reduced result', () => { + const errorDefs = { + ZEBRA: { description: 'Z', statusCode: 500 }, + ALPHA: { description: 'A', statusCode: 500 }, + MIDDLE: { description: 'M', statusCode: 500 } + } + + const result = Object.entries(errorDefs) + .sort() + .reduce((m, [k, { description, statusCode, data }]) => { + return Object.defineProperty(m, k, { + get: () => { + const metadata = { description } + if (data) metadata.data = data + return new AdaptError(k, statusCode, metadata) + }, + enumerable: true + }) + }, {}) + + const keys = Object.keys(result) + assert.deepEqual(keys, ['ALPHA', 'MIDDLE', 'ZEBRA']) + }) + + it('should make error properties enumerable', () => { + const errorDefs = { + ENUM_ERR: { description: 'Enumerable', statusCode: 500 } + } + + const result = Object.entries(errorDefs) + .sort() + .reduce((m, [k, { description, statusCode, data }]) => { + return Object.defineProperty(m, k, { + get: () => { + const metadata = { description } + if (data) metadata.data = data + return new AdaptError(k, statusCode, metadata) + }, + enumerable: true + }) + }, {}) + + const descriptor = Object.getOwnPropertyDescriptor(result, 'ENUM_ERR') + assert.equal(descriptor.enumerable, true) + assert.equal(typeof descriptor.get, 'function') + }) + }) + + describe('Shipped error definitions', () => { + it('should have valid node-core error definitions', () => { + const nodeCoreErrors = JSON.parse( + readFileSync(join(__dirname, '..', 'errors', 'node-core.json'), 'utf8') + ) + + const expectedCodes = ['EACCES', 'EADDRINUSE', 'ECONNREFUSED', 'EEXIST', 'ENOENT', 'ENOTEMPTY', 'MODULE_NOT_FOUND'] + expectedCodes.forEach(code => { + assert.ok(nodeCoreErrors[code], `Missing expected error code: ${code}`) + assert.equal(typeof nodeCoreErrors[code].description, 'string') + assert.equal(typeof nodeCoreErrors[code].statusCode, 'number') + }) + }) + + it('should have valid adapt error definitions', () => { + const adaptErrors = JSON.parse( + readFileSync(join(__dirname, '..', 'errors', 'adapt-errors.json'), 'utf8') + ) + + const expectedCodes = ['FUNC_NOT_OVERRIDDEN', 'FUNC_DISABLED', 'SERVER_ERROR', 'INVALID_PARAMS', 'NOT_FOUND'] + expectedCodes.forEach(code => { + assert.ok(adaptErrors[code], `Missing expected error code: ${code}`) + assert.equal(typeof adaptErrors[code].description, 'string') + assert.equal(typeof adaptErrors[code].statusCode, 'number') + }) + }) + + it('should have valid test error definitions', () => { + const testErrors = JSON.parse( + readFileSync(join(__dirname, 'data', 'test-errors.json'), 'utf8') + ) + + assert.ok(testErrors.TEST_ERROR) + assert.ok(testErrors.TEST_NOT_FOUND) + assert.ok(testErrors.TEST_VALIDATION_ERROR) + assert.equal(testErrors.TEST_NOT_FOUND.statusCode, 404) + assert.equal(testErrors.TEST_VALIDATION_ERROR.statusCode, 400) + }) + + it('should use uppercase with underscores for all error codes', () => { + const files = ['node-core.json', 'adapt-errors.json'] + files.forEach(file => { + const errors = JSON.parse( + readFileSync(join(__dirname, '..', 'errors', file), 'utf8') + ) + Object.keys(errors).forEach(code => { + assert.ok(/^[A-Z][A-Z0-9_]*$/.test(code), `Invalid error code format: ${code} in ${file}`) + }) + }) + }) + }) + + describe('Duplicate error detection', () => { + it('should detect duplicate error codes across files', () => { + writeFileSync( + join(tempDir, 'errors', 'first.json'), + JSON.stringify({ DUPLICATE: { description: 'First', statusCode: 500 } }) + ) + writeFileSync( + join(tempDir, 'errors', 'second.json'), + JSON.stringify({ DUPLICATE: { description: 'Second', statusCode: 400 } }) + ) + + const allDefs = {} + const duplicates = [] + + const files = ['first.json', 'second.json'] + files.forEach(file => { + const contents = JSON.parse( + readFileSync(join(tempDir, 'errors', file), 'utf8') + ) + Object.entries(contents).forEach(([k, v]) => { + if (allDefs[k]) { + duplicates.push(k) + } else { + allDefs[k] = v + } + }) + }) + + assert.equal(duplicates.length, 1) + assert.equal(duplicates[0], 'DUPLICATE') + }) + }) +}) diff --git a/tests/data/test-errors.json b/tests/data/test-errors.json new file mode 100644 index 0000000..4cd6f38 --- /dev/null +++ b/tests/data/test-errors.json @@ -0,0 +1,21 @@ +{ + "TEST_ERROR": { + "description": "A test error", + "statusCode": 500 + }, + "TEST_NOT_FOUND": { + "description": "Test item not found", + "statusCode": 404, + "data": { + "id": "Item identifier" + } + }, + "TEST_VALIDATION_ERROR": { + "description": "Test validation failed", + "statusCode": 400, + "data": { + "field": "The invalid field", + "value": "The invalid value" + } + } +}