654 lines
19 KiB
TypeScript
654 lines
19 KiB
TypeScript
/**
|
|
* Domain Module Unit Tests
|
|
* Tests for category inference, contract extraction, and route discovery.
|
|
* Uses Node's built-in test runner with AAA (Arrange, Act, Assert) pattern.
|
|
*/
|
|
import { test } from 'node:test'
|
|
import assert from 'node:assert'
|
|
import { inferCategory } from '../domain/category.js'
|
|
import { extractContract } from '../domain/contract.js'
|
|
import { discoverRoutes } from '../domain/discovery.js'
|
|
// ============================================================================
|
|
import type { RouteContract } from '../types.js'
|
|
// Category Inference Tests
|
|
// ============================================================================
|
|
test('inferCategory returns utility for exact utility path /reset', () => {
|
|
// Arrange
|
|
const path = '/reset'
|
|
const method = 'POST'
|
|
const override = undefined
|
|
// Act
|
|
const result = inferCategory(path, method, override)
|
|
// Assert
|
|
assert.strictEqual(result, 'utility')
|
|
})
|
|
test('inferCategory returns utility for utility path with trailing slash', () => {
|
|
// Arrange
|
|
const path = '/health/'
|
|
const method = 'GET'
|
|
const override = undefined
|
|
// Act
|
|
const result = inferCategory(path, method, override)
|
|
// Assert
|
|
assert.strictEqual(result, 'utility')
|
|
})
|
|
test('inferCategory returns utility for all registered utility paths', () => {
|
|
// Arrange
|
|
const utilityPaths = ['/ping', '/login', '/logout', '/auth', '/callback', '/purge', '/clear', '/initialize', '/setup', '/webhook']
|
|
// Act & Assert
|
|
for (const path of utilityPaths) {
|
|
const result = inferCategory(path, 'GET', undefined)
|
|
assert.strictEqual(result, 'utility', `Expected utility for path ${path}`)
|
|
}
|
|
})
|
|
test('inferCategory returns observer for GET method on non-utility path', () => {
|
|
// Arrange
|
|
const path = '/users'
|
|
const method = 'GET'
|
|
const override = undefined
|
|
// Act
|
|
const result = inferCategory(path, method, override)
|
|
// Assert
|
|
assert.strictEqual(result, 'observer')
|
|
})
|
|
test('inferCategory returns observer for observer suffix /search', () => {
|
|
// Arrange
|
|
const path = '/users/search'
|
|
const method = 'POST'
|
|
const override = undefined
|
|
// Act
|
|
const result = inferCategory(path, method, override)
|
|
// Assert
|
|
assert.strictEqual(result, 'observer')
|
|
})
|
|
test('inferCategory returns observer for observer suffix /count', () => {
|
|
// Arrange
|
|
const path = '/items/count'
|
|
const method = 'POST'
|
|
const override = undefined
|
|
// Act
|
|
const result = inferCategory(path, method, override)
|
|
// Assert
|
|
assert.strictEqual(result, 'observer')
|
|
})
|
|
test('inferCategory returns observer for observer suffix /stats', () => {
|
|
// Arrange
|
|
const path = '/metrics/stats'
|
|
const method = 'POST'
|
|
const override = undefined
|
|
// Act
|
|
const result = inferCategory(path, method, override)
|
|
// Assert
|
|
assert.strictEqual(result, 'observer')
|
|
})
|
|
test('inferCategory returns observer for observer suffix /status', () => {
|
|
// Arrange
|
|
const path = '/system/status'
|
|
const method = 'POST'
|
|
const override = undefined
|
|
// Act
|
|
const result = inferCategory(path, method, override)
|
|
// Assert
|
|
assert.strictEqual(result, 'observer')
|
|
})
|
|
test('inferCategory returns constructor for POST on collection path', () => {
|
|
// Arrange
|
|
const path = '/users'
|
|
const method = 'POST'
|
|
const override = undefined
|
|
// Act
|
|
const result = inferCategory(path, method, override)
|
|
// Assert
|
|
assert.strictEqual(result, 'constructor')
|
|
})
|
|
test('inferCategory returns constructor for POST on nested collection path', () => {
|
|
// Arrange
|
|
const path = '/api/v1/users'
|
|
const method = 'POST'
|
|
const override = undefined
|
|
// Act
|
|
const result = inferCategory(path, method, override)
|
|
// Assert
|
|
assert.strictEqual(result, 'constructor')
|
|
})
|
|
test('inferCategory returns mutator for PUT method', () => {
|
|
// Arrange
|
|
const path = '/users/:id'
|
|
const method = 'PUT'
|
|
const override = undefined
|
|
// Act
|
|
const result = inferCategory(path, method, override)
|
|
// Assert
|
|
assert.strictEqual(result, 'mutator')
|
|
})
|
|
test('inferCategory returns mutator for PATCH method', () => {
|
|
// Arrange
|
|
const path = '/users/:id'
|
|
const method = 'PATCH'
|
|
const override = undefined
|
|
// Act
|
|
const result = inferCategory(path, method, override)
|
|
// Assert
|
|
assert.strictEqual(result, 'mutator')
|
|
})
|
|
test('inferCategory returns mutator for DELETE method', () => {
|
|
// Arrange
|
|
const path = '/users/:id'
|
|
const method = 'DELETE'
|
|
const override = undefined
|
|
// Act
|
|
const result = inferCategory(path, method, override)
|
|
// Assert
|
|
assert.strictEqual(result, 'mutator')
|
|
})
|
|
test('inferCategory returns mutator for POST with path parameter', () => {
|
|
// Arrange
|
|
const path = '/users/:id'
|
|
const method = 'POST'
|
|
const override = undefined
|
|
// Act
|
|
const result = inferCategory(path, method, override)
|
|
// Assert
|
|
assert.strictEqual(result, 'mutator')
|
|
})
|
|
test('inferCategory returns observer for POST without collection path or path param', () => {
|
|
// Arrange
|
|
const path = '/search'
|
|
const method = 'POST'
|
|
const override = undefined
|
|
// Act
|
|
const result = inferCategory(path, method, override)
|
|
// Assert
|
|
assert.strictEqual(result, 'observer')
|
|
})
|
|
test('inferCategory handles method case insensitively', () => {
|
|
// Arrange
|
|
const path = '/users'
|
|
const method = 'get'
|
|
const override = undefined
|
|
// Act
|
|
const result = inferCategory(path, method, override)
|
|
// Assert
|
|
assert.strictEqual(result, 'observer')
|
|
})
|
|
test('inferCategory respects override when provided', () => {
|
|
// Arrange
|
|
const path = '/users'
|
|
const method = 'POST'
|
|
const override = 'observer'
|
|
// Act
|
|
const result = inferCategory(path, method, override)
|
|
// Assert
|
|
assert.strictEqual(result, 'observer')
|
|
})
|
|
test('inferCategory ignores empty string override', () => {
|
|
// Arrange
|
|
const path = '/users'
|
|
const method = 'POST'
|
|
const override = ''
|
|
// Act
|
|
const result = inferCategory(path, method, override)
|
|
// Assert
|
|
assert.strictEqual(result, 'constructor')
|
|
})
|
|
test('inferCategory returns utility when override is utility', () => {
|
|
// Arrange
|
|
const path = '/users'
|
|
const method = 'POST'
|
|
const override = 'utility'
|
|
// Act
|
|
const result = inferCategory(path, method, override)
|
|
// Assert
|
|
assert.strictEqual(result, 'utility')
|
|
})
|
|
test('inferCategory returns mutator when override is mutator', () => {
|
|
// Arrange
|
|
const path = '/users'
|
|
const method = 'GET'
|
|
const override = 'mutator'
|
|
// Act
|
|
const result = inferCategory(path, method, override)
|
|
// Assert
|
|
assert.strictEqual(result, 'mutator')
|
|
})
|
|
test('inferCategory returns observer as default fallback', () => {
|
|
// Arrange
|
|
const path = '/'
|
|
const method = 'HEAD'
|
|
const override = undefined
|
|
// Act
|
|
const result = inferCategory(path, method, override)
|
|
// Assert
|
|
assert.strictEqual(result, 'observer')
|
|
})
|
|
// ============================================================================
|
|
// Contract Extraction Tests
|
|
// ============================================================================
|
|
test('extractContract extracts basic contract with defaults', () => {
|
|
// Arrange
|
|
const path = '/users'
|
|
const method = 'GET'
|
|
const schema = undefined
|
|
// Act
|
|
const result = extractContract(path, method, schema)
|
|
// Assert
|
|
assert.strictEqual(result.path, '/users')
|
|
assert.strictEqual(result.method, 'GET')
|
|
assert.strictEqual(result.category, 'observer')
|
|
assert.deepStrictEqual(result.requires, [])
|
|
assert.deepStrictEqual(result.ensures, [])
|
|
assert.deepStrictEqual(result.invariants, [])
|
|
assert.deepStrictEqual(result.regexPatterns, {})
|
|
assert.strictEqual(result.validateRuntime, true)
|
|
assert.deepStrictEqual(result.schema, {})
|
|
})
|
|
test('extractContract extracts x-requires array', () => {
|
|
// Arrange
|
|
const path = '/users'
|
|
const method = 'POST'
|
|
const schema = {
|
|
'x-requires': ['admin', 'authenticated'],
|
|
}
|
|
// Act
|
|
const result = extractContract(path, method, schema)
|
|
// Assert
|
|
assert.deepStrictEqual(result.requires, ['admin', 'authenticated'])
|
|
})
|
|
test('extractContract extracts x-ensures array', () => {
|
|
// Arrange
|
|
const path = '/users'
|
|
const method = 'POST'
|
|
const schema = {
|
|
'x-ensures': ['user.created', 'email.sent'],
|
|
}
|
|
// Act
|
|
const result = extractContract(path, method, schema)
|
|
// Assert
|
|
assert.deepStrictEqual(result.ensures, ['user.created', 'email.sent'])
|
|
})
|
|
test('extractContract ignores x-invariants (removed in v1.0)', () => {
|
|
// Arrange
|
|
const path = '/users'
|
|
const method = 'POST'
|
|
const schema = {
|
|
'x-invariants': ['unique.email', 'active.status'],
|
|
}
|
|
// Act
|
|
const result = extractContract(path, method, schema)
|
|
// Assert
|
|
assert.deepStrictEqual(result.invariants, [])
|
|
})
|
|
test('extractContract ignores x-regex (removed in v1.0)', () => {
|
|
// Arrange
|
|
const path = '/users'
|
|
const method = 'POST'
|
|
const schema = {
|
|
'x-regex': {
|
|
email: '^[\\w.-]+@[\\w.-]+\\.\\w+$',
|
|
phone: '^\\+?[1-9]\\d{1,14}$',
|
|
},
|
|
}
|
|
// Act
|
|
const result = extractContract(path, method, schema)
|
|
// Assert
|
|
assert.deepStrictEqual(result.regexPatterns, {})
|
|
})
|
|
test('extractContract handles x-validate-runtime false', () => {
|
|
// Arrange
|
|
const path = '/users'
|
|
const method = 'GET'
|
|
const schema = {
|
|
'x-validate-runtime': false,
|
|
}
|
|
// Act
|
|
const result = extractContract(path, method, schema)
|
|
// Assert
|
|
assert.strictEqual(result.validateRuntime, false)
|
|
})
|
|
test('extractContract defaults validateRuntime to true when not specified', () => {
|
|
// Arrange
|
|
const path = '/users'
|
|
const method = 'GET'
|
|
const schema = {}
|
|
// Act
|
|
const result = extractContract(path, method, schema)
|
|
// Assert
|
|
assert.strictEqual(result.validateRuntime, true)
|
|
})
|
|
test('extractContract respects x-category override', () => {
|
|
// Arrange
|
|
const path = '/users'
|
|
const method = 'POST'
|
|
const schema = {
|
|
'x-category': 'utility',
|
|
}
|
|
// Act
|
|
const result = extractContract(path, method, schema)
|
|
// Assert
|
|
assert.strictEqual(result.category, 'utility')
|
|
})
|
|
test('extractContract ignores non-string x-category', () => {
|
|
// Arrange
|
|
const path = '/users'
|
|
const method = 'POST'
|
|
const schema = {
|
|
'x-category': 123,
|
|
}
|
|
// Act
|
|
const result = extractContract(path, method, schema)
|
|
// Assert
|
|
assert.strictEqual(result.category, 'constructor')
|
|
})
|
|
test('extractContract handles empty schema object', () => {
|
|
// Arrange
|
|
const path = '/users'
|
|
const method = 'GET'
|
|
const schema = {}
|
|
// Act
|
|
const result = extractContract(path, method, schema)
|
|
// Assert
|
|
assert.deepStrictEqual(result.requires, [])
|
|
assert.deepStrictEqual(result.ensures, [])
|
|
assert.deepStrictEqual(result.invariants, [])
|
|
assert.deepStrictEqual(result.regexPatterns, {})
|
|
assert.strictEqual(result.validateRuntime, true)
|
|
})
|
|
test('extractContract handles null x-regex gracefully', () => {
|
|
// Arrange
|
|
const path = '/users'
|
|
const method = 'GET'
|
|
const schema = {
|
|
'x-regex': null,
|
|
}
|
|
// Act
|
|
const result = extractContract(path, method, schema)
|
|
// Assert
|
|
assert.deepStrictEqual(result.regexPatterns, {})
|
|
})
|
|
test('extractContract handles non-object x-regex gracefully', () => {
|
|
// Arrange
|
|
const path = '/users'
|
|
const method = 'GET'
|
|
const schema = {
|
|
'x-regex': 'invalid',
|
|
}
|
|
// Act
|
|
const result = extractContract(path, method, schema)
|
|
// Assert
|
|
assert.deepStrictEqual(result.regexPatterns, {})
|
|
})
|
|
test('extractContract normalizes method to uppercase', () => {
|
|
// Arrange
|
|
const path = '/users'
|
|
const method = 'post'
|
|
const schema = undefined
|
|
// Act
|
|
const result = extractContract(path, method, schema)
|
|
// Assert
|
|
assert.strictEqual(result.method, 'POST')
|
|
})
|
|
test('extractContract preserves original schema in contract', () => {
|
|
// Arrange
|
|
const path = '/users'
|
|
const method = 'GET'
|
|
const schema = {
|
|
type: 'object',
|
|
properties: {
|
|
name: { type: 'string' },
|
|
},
|
|
}
|
|
// Act
|
|
const result = extractContract(path, method, schema)
|
|
// Assert
|
|
assert.deepStrictEqual(result.schema, schema)
|
|
})
|
|
// ============================================================================
|
|
// Route Discovery Tests
|
|
// ============================================================================
|
|
test('discoverRoutes returns empty array for empty routes', () => {
|
|
// Arrange
|
|
const instance = { routes: [] }
|
|
// Act
|
|
const result = discoverRoutes(instance)
|
|
// Assert
|
|
assert.deepStrictEqual(result, [])
|
|
})
|
|
test('discoverRoutes returns empty array when routes is undefined', () => {
|
|
// Arrange
|
|
const instance = {}
|
|
// Act
|
|
const result = discoverRoutes(instance)
|
|
// Assert
|
|
assert.deepStrictEqual(result, [])
|
|
})
|
|
test('discoverRoutes discovers single route', () => {
|
|
// Arrange
|
|
const instance = {
|
|
routes: [
|
|
{ method: 'GET', url: '/users', schema: {} },
|
|
],
|
|
}
|
|
// Act
|
|
const result = discoverRoutes(instance)
|
|
// Assert
|
|
assert.strictEqual(result.length, 1)
|
|
assert.strictEqual(result[0]!.path, '/users')
|
|
assert.strictEqual(result[0]!.method, 'GET')
|
|
assert.strictEqual(result[0]!.category, 'observer')
|
|
})
|
|
test('discoverRoutes discovers multiple routes', () => {
|
|
// Arrange
|
|
const instance = {
|
|
routes: [
|
|
{ method: 'GET', url: '/users' },
|
|
{ method: 'POST', url: '/users' },
|
|
{ method: 'GET', url: '/users/:id' },
|
|
],
|
|
}
|
|
// Act
|
|
const result = discoverRoutes(instance)
|
|
// Assert
|
|
assert.strictEqual(result.length, 3)
|
|
assert.strictEqual(result[0]!.category, 'observer')
|
|
assert.strictEqual(result[1]!.category, 'constructor')
|
|
assert.strictEqual(result[2]!.category, 'observer')
|
|
})
|
|
test('discoverRoutes handles routes with schemas', () => {
|
|
// Arrange
|
|
const instance = {
|
|
routes: [
|
|
{
|
|
method: 'POST',
|
|
url: '/users',
|
|
schema: {
|
|
'x-requires': ['admin'],
|
|
'x-ensures': ['user.created'],
|
|
'x-category': 'constructor',
|
|
},
|
|
},
|
|
],
|
|
}
|
|
// Act
|
|
const result = discoverRoutes(instance)
|
|
// Assert
|
|
assert.strictEqual(result.length, 1)
|
|
assert.strictEqual(result[0]!.path, '/users')
|
|
assert.deepStrictEqual(result[0]!.requires, ['admin'])
|
|
assert.deepStrictEqual(result[0]!.ensures, ['user.created'])
|
|
assert.strictEqual(result[0]!.category, 'constructor')
|
|
})
|
|
test('discoverRoutes handles routes without schemas', () => {
|
|
// Arrange
|
|
const instance = {
|
|
routes: [
|
|
{ method: 'DELETE', url: '/users/:id' },
|
|
],
|
|
}
|
|
// Act
|
|
const result = discoverRoutes(instance)
|
|
// Assert
|
|
assert.strictEqual(result.length, 1)
|
|
assert.strictEqual(result[0]!.path, '/users/:id')
|
|
assert.strictEqual(result[0]!.method, 'DELETE')
|
|
assert.strictEqual(result[0]!.category, 'mutator')
|
|
assert.deepStrictEqual(result[0]!.requires, [])
|
|
})
|
|
test('discoverRoutes handles mixed route configurations', () => {
|
|
// Arrange
|
|
const instance = {
|
|
routes: [
|
|
{ method: 'GET', url: '/health' },
|
|
{ method: 'POST', url: '/users', schema: { 'x-requires': ['auth'] } },
|
|
{ method: 'GET', url: '/users/:id' },
|
|
{ method: 'DELETE', url: '/users/:id' },
|
|
],
|
|
}
|
|
// Act
|
|
const result = discoverRoutes(instance)
|
|
// Assert
|
|
assert.strictEqual(result.length, 4)
|
|
assert.strictEqual(result[0]!.category, 'utility')
|
|
assert.deepStrictEqual(result[0]!.requires, [])
|
|
assert.strictEqual(result[1]!.category, 'constructor')
|
|
assert.deepStrictEqual(result[1]!.requires, ['auth'])
|
|
assert.strictEqual(result[2]!.category, 'observer')
|
|
assert.deepStrictEqual(result[2]!.invariants, [])
|
|
assert.strictEqual(result[3]!.category, 'mutator')
|
|
})
|
|
test('discoverRoutes ignores x-regex (removed in v1.0)', () => {
|
|
// Arrange
|
|
const instance = {
|
|
routes: [
|
|
{
|
|
method: 'POST',
|
|
url: '/users',
|
|
schema: {
|
|
'x-regex': {
|
|
email: '^[\\w.-]+@[\\w.-]+\\.\\w+$',
|
|
},
|
|
},
|
|
},
|
|
],
|
|
}
|
|
// Act
|
|
const result = discoverRoutes(instance)
|
|
// Assert
|
|
assert.deepStrictEqual(result[0]!.regexPatterns, {})
|
|
})
|
|
test('discoverRoutes handles route with validateRuntime disabled', () => {
|
|
// Arrange
|
|
const instance = {
|
|
routes: [
|
|
{
|
|
method: 'GET',
|
|
url: '/public',
|
|
schema: {
|
|
'x-validate-runtime': false,
|
|
},
|
|
},
|
|
],
|
|
}
|
|
// Act
|
|
const result = discoverRoutes(instance)
|
|
// Assert
|
|
assert.strictEqual(result[0]!.validateRuntime, false)
|
|
})
|
|
test('discoverRoutes discovers utility routes correctly', () => {
|
|
// Arrange
|
|
const instance = {
|
|
routes: [
|
|
{ method: 'GET', url: '/reset' },
|
|
{ method: 'POST', url: '/login' },
|
|
{ method: 'GET', url: '/callback' },
|
|
],
|
|
}
|
|
// Act
|
|
const result = discoverRoutes(instance)
|
|
// Assert
|
|
assert.strictEqual(result.length, 3)
|
|
for (const contract of result) {
|
|
assert.strictEqual(contract.category, 'utility')
|
|
}
|
|
})
|
|
test('discoverRoutes discovers observer suffix routes', () => {
|
|
// Arrange
|
|
const instance = {
|
|
routes: [
|
|
{ method: 'POST', url: '/users/search' },
|
|
{ method: 'GET', url: '/items/count' },
|
|
{ method: 'POST', url: '/system/stats' },
|
|
{ method: 'GET', url: '/service/status' },
|
|
],
|
|
}
|
|
// Act
|
|
const result = discoverRoutes(instance)
|
|
// Assert
|
|
assert.strictEqual(result.length, 4)
|
|
for (const contract of result) {
|
|
assert.strictEqual(contract.category, 'observer')
|
|
}
|
|
})
|
|
test('discoverRoutes handles non-array routes property', () => {
|
|
// Arrange
|
|
const instance = {
|
|
routes: 'invalid' as unknown as Array<{ method: string; url: string; schema?: Record<string, unknown> }>,
|
|
}
|
|
// Act
|
|
const result = discoverRoutes(instance)
|
|
// Assert
|
|
assert.deepStrictEqual(result, [])
|
|
})
|
|
test('discoverRoutes handles null instance gracefully', () => {
|
|
// Arrange
|
|
const instance = null as unknown as { routes?: Array<{ method: string; url: string; schema?: Record<string, unknown> }> }
|
|
// Act & Assert
|
|
assert.throws(() => {
|
|
discoverRoutes(instance)
|
|
}, /Cannot read properties of null/)
|
|
})
|
|
test('discoverRoutes handles route with empty schema', () => {
|
|
// Arrange
|
|
const instance = {
|
|
routes: [
|
|
{ method: 'GET', url: '/empty', schema: {} },
|
|
],
|
|
}
|
|
// Act
|
|
const result = discoverRoutes(instance)
|
|
// Assert
|
|
assert.strictEqual(result.length, 1)
|
|
assert.strictEqual(result[0]!.path, '/empty')
|
|
assert.deepStrictEqual(result[0]!.requires, [])
|
|
assert.deepStrictEqual(result[0]!.ensures, [])
|
|
assert.deepStrictEqual(result[0]!.invariants, [])
|
|
})
|
|
test('discoverRoutes handles route with all x-annotations', () => {
|
|
// Arrange
|
|
const instance = {
|
|
routes: [
|
|
{
|
|
method: 'POST',
|
|
url: '/users',
|
|
schema: {
|
|
'x-category': 'constructor',
|
|
'x-requires': ['auth', 'admin'],
|
|
'x-ensures': ['created'],
|
|
'x-invariants': ['unique'],
|
|
'x-regex': { name: '^[a-z]+$' },
|
|
'x-validate-runtime': true,
|
|
},
|
|
},
|
|
],
|
|
}
|
|
// Act
|
|
const result = discoverRoutes(instance)
|
|
// Assert
|
|
assert.strictEqual(result.length, 1)
|
|
const contract = result[0]!
|
|
assert.strictEqual(contract.category, 'constructor')
|
|
assert.deepStrictEqual(contract.requires, ['auth', 'admin'])
|
|
assert.deepStrictEqual(contract.ensures, ['created'])
|
|
assert.deepStrictEqual(contract.invariants, [])
|
|
assert.deepStrictEqual(contract.regexPatterns, {})
|
|
assert.strictEqual(contract.validateRuntime, true)
|
|
}) |