diff --git a/lib/headers.js b/lib/headers.js new file mode 100644 index 0000000..47f8a60 --- /dev/null +++ b/lib/headers.js @@ -0,0 +1,124 @@ +/** + * This file implements the Web API Headers interface + * (https://developer.mozilla.org/en-US/docs/Web/API/Headers) + * while maintaining compatibility with Express.js style header access. + */ + +/** + * Creates a Headers object that implements both Express.js style access + * and the Web API Headers interface + * + * @param {Object} headers - Initial headers object + * @returns {HeaderWebAPI} - A proxy that implements the HeaderWebAPI interface + */ +function createHeaders(headers = {}) { + return new Proxy(headers, { + get(target, prop) { + // Direct property access for Express.js style + if (typeof prop === 'string' && prop in target) { + return target[prop]; + } + + // Handle Headers interface methods + switch (prop) { + case 'get': + return (name) => { + const lowerName = name.toLowerCase(); + // Special case for referer/referrer + if (lowerName === 'referer' || lowerName === 'referrer') { + return target.referrer || target.referer; + } + return target[lowerName]; + }; + case 'getAll': + return (name) => { + const value = target[name.toLowerCase()]; + if (!value) { + return []; + } + return Array.isArray(value) ? value : [value]; + }; + case 'has': + return (name) => name.toLowerCase() in target; + case 'set': + return (name, value) => { + // eslint-disable-next-line no-param-reassign + target[name.toLowerCase()] = value; + }; + case 'append': + return (name, value) => { + const lowerName = name.toLowerCase(); + if (lowerName in target) { + const existingValue = target[lowerName]; + if (Array.isArray(existingValue)) { + existingValue.push(value); + } else { + // eslint-disable-next-line no-param-reassign + target[lowerName] = [existingValue, value]; + } + } else { + // eslint-disable-next-line no-param-reassign + target[lowerName] = value; + } + }; + case 'delete': + return (name) => { + // eslint-disable-next-line no-param-reassign + delete target[name.toLowerCase()]; + }; + case 'forEach': + return (callback, thisArg) => { + Object.entries(target).forEach(([key, value]) => { + callback.call(thisArg, value, key, target); + }); + }; + case 'entries': + return () => Object.entries(target)[Symbol.iterator](); + case 'keys': + return () => Object.keys(target)[Symbol.iterator](); + case 'values': + return () => Object.values(target)[Symbol.iterator](); + case Symbol.iterator: + return ( + target[Symbol.iterator] || + function* iterator() { + yield* Object.entries(target); + } + ); + default: + return target[prop]; + } + }, + set(target, prop, value) { + if (typeof prop === 'string') { + // eslint-disable-next-line no-param-reassign + target[prop.toLowerCase()] = value; + return true; + } + return false; + }, + has(target, prop) { + if (typeof prop === 'string') { + return prop.toLowerCase() in target; + } + return prop in target; + }, + deleteProperty(target, prop) { + if (typeof prop === 'string') { + // eslint-disable-next-line no-param-reassign + delete target[prop.toLowerCase()]; + return true; + } + // eslint-disable-next-line no-param-reassign + return delete target[prop]; + }, + ownKeys(target) { + return Object.keys(target); + }, + getOwnPropertyDescriptor(target, prop) { + return Object.getOwnPropertyDescriptor(target, prop); + } + }); +} + +module.exports = { createHeaders }; diff --git a/lib/http-mock.d.ts b/lib/http-mock.d.ts index b644c88..aee66ef 100644 --- a/lib/http-mock.d.ts +++ b/lib/http-mock.d.ts @@ -19,6 +19,7 @@ export interface Cookies { } export interface Headers { + // Standard HTTP headers accept?: string; 'accept-language'?: string; 'accept-patch'?: string; @@ -73,9 +74,35 @@ export interface Headers { via?: string; warning?: string; 'www-authenticate'?: string; + + // Support for arbitrary headers [header: string]: string | string[] | undefined; } +/** + * HeaderWebAPI interface combines the existing Headers type with + * standard Web API Headers interface methods for better compatibility + * with browser environments. + */ +export interface HeaderWebAPI { + // Include all the header properties + [header: string]: any; // 'any' to accommodate both header values and methods + + // Web API Headers methods + append(name: string, value: string): void; + delete(name: string): void; + get(name: string): string | null; + has(name: string): boolean; + set(name: string, value: string): void; + forEach(callbackfn: (value: string, key: string, parent: HeaderWebAPI) => void, thisArg?: any): void; + + // Iterator methods + entries(): IterableIterator<[string, string]>; + keys(): IterableIterator; + values(): IterableIterator; + [Symbol.iterator](): IterableIterator<[string, string]>; +} + export interface Query { [key: string]: any; } @@ -121,6 +148,8 @@ export type MockRequest = T & { _setBody: (body?: Body) => void; _addBody: (key: string, value?: any) => void; + headers: HeaderWebAPI; + // Support custom properties appended on Request objects. [key: string]: any; }; @@ -139,7 +168,7 @@ export type ResponseCookie = { export type MockResponse = T & { _isEndCalled: () => boolean; - _getHeaders: () => Headers; + _getHeaders: () => HeaderWebAPI; _getData: () => any; _getJSONData: () => any; _getBuffer: () => Buffer; diff --git a/lib/mockRequest.js b/lib/mockRequest.js index 289ac3b..02d9425 100644 --- a/lib/mockRequest.js +++ b/lib/mockRequest.js @@ -35,6 +35,7 @@ const parseRange = require('range-parser'); let { EventEmitter } = require('events'); const querystring = require('querystring'); const utils = require('./utils'); +const { createHeaders } = require('./headers'); const standardRequestOptions = [ 'method', @@ -73,7 +74,11 @@ function createRequest(options = {}) { if (options.signedCookies) { mockRequest.signedCookies = options.signedCookies; } - mockRequest.headers = options.headers ? utils.convertKeysToLowerCase(options.headers) : {}; + + // Create headers using the Headers.js module + const originalHeaders = options.headers ? utils.convertKeysToLowerCase(options.headers) : {}; + mockRequest.headers = createHeaders(originalHeaders); + mockRequest.body = options.body ? options.body : {}; mockRequest.query = options.query ? options.query : {}; mockRequest.files = options.files ? options.files : {}; diff --git a/package-lock.json b/package-lock.json index b198942..478e582 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "node-mocks-http", - "version": "1.16.1", + "version": "1.16.2", "license": "MIT", "dependencies": { "accepts": "^1.3.7", diff --git a/test/lib/headers.spec.js b/test/lib/headers.spec.js new file mode 100644 index 0000000..671a0cd --- /dev/null +++ b/test/lib/headers.spec.js @@ -0,0 +1,238 @@ +/** + * Test: headers.spec.js + * + * This file tests the Headers implementation which provides both + * Express.js style header access and Web API Headers interface functionality. + */ +const { expect } = require('chai'); +const { createHeaders } = require('../../lib/headers'); + +describe('Headers', () => { + describe('Headers basic HTTP', () => { + it('should create an empty headers object', () => { + const headers = createHeaders(); + expect(headers).to.be.an('object'); + expect(Object.keys(headers).length).to.equal(0); + }); + + it('should initialize with provided headers', () => { + const initialHeaders = { 'content-type': 'application/json' }; + const headers = createHeaders(initialHeaders); + + expect(headers['content-type']).to.equal('application/json'); + }); + }); + + describe('Headers Web API Methods', () => { + describe('#get()', () => { + it('should get a header value', () => { + const headers = createHeaders({ 'content-type': 'application/json' }); + + expect(headers.get('content-type')).to.equal('application/json'); + expect(headers.get('Content-Type')).to.equal('application/json'); + }); + + it('should return undefined for non-existent headers', () => { + const headers = createHeaders(); + + expect(headers.get('content-type')).to.be.undefined; + }); + + it('should handle the referer/referrer special case', () => { + const headersWithReferer = createHeaders({ referer: 'http://example.com' }); + expect(headersWithReferer.get('referrer')).to.equal('http://example.com'); + + const headersWithReferrer = createHeaders({ referrer: 'http://example.com' }); + expect(headersWithReferrer.get('referer')).to.equal('http://example.com'); + }); + }); + + describe('#getAll()', () => { + it('should get all values for a header as an array', () => { + const headers = createHeaders({ 'set-cookie': ['cookie1=value1', 'cookie2=value2'] }); + + expect(headers.getAll('set-cookie')).to.deep.equal(['cookie1=value1', 'cookie2=value2']); + }); + + it('should return a single value as an array', () => { + const headers = createHeaders({ 'content-type': 'application/json' }); + + expect(headers.getAll('content-type')).to.deep.equal(['application/json']); + }); + + it('should return an empty array for non-existent headers', () => { + const headers = createHeaders(); + + expect(headers.getAll('content-type')).to.deep.equal([]); + }); + }); + + describe('#has()', () => { + it('should check if a header exists', () => { + const headers = createHeaders({ 'content-type': 'application/json' }); + + expect(headers.has('content-type')).to.be.true; + expect(headers.has('Content-Type')).to.be.true; + }); + + it('should return false for non-existent headers', () => { + const headers = createHeaders(); + + expect(headers.has('content-type')).to.be.false; + }); + }); + + describe('#set()', () => { + it('should set a header value', () => { + const headers = createHeaders(); + + headers.set('content-type', 'application/json'); + expect(headers['content-type']).to.equal('application/json'); + }); + + it('should overwrite existing headers', () => { + const headers = createHeaders({ 'content-type': 'text/html' }); + + headers.set('Content-Type', 'application/json'); + expect(headers['content-type']).to.equal('application/json'); + }); + }); + + describe('#append()', () => { + it('should append a value to a non-existent header', () => { + const headers = createHeaders(); + + headers.append('content-type', 'application/json'); + expect(headers['content-type']).to.equal('application/json'); + }); + + it('should convert a single value to an array when appending', () => { + const headers = createHeaders({ accept: 'text/html' }); + + headers.append('accept', 'application/json'); + expect(headers.accept).to.deep.equal(['text/html', 'application/json']); + }); + + it('should append to an existing array of values', () => { + const headers = createHeaders({ 'set-cookie': ['cookie1=value1'] }); + + headers.append('set-cookie', 'cookie2=value2'); + expect(headers['set-cookie']).to.deep.equal(['cookie1=value1', 'cookie2=value2']); + }); + }); + + describe('#delete()', () => { + it('should delete a header', () => { + const headers = createHeaders({ 'content-type': 'application/json' }); + + headers.delete('content-type'); + expect(headers['content-type']).to.be.undefined; + expect('content-type' in headers).to.be.false; + }); + + it('should handle case-insensitive deletion', () => { + const headers = createHeaders({ 'content-type': 'application/json' }); + + headers.delete('Content-Type'); + expect('content-type' in headers).to.be.false; + }); + }); + + describe('#forEach()', () => { + it('should iterate over all headers', () => { + const headers = createHeaders({ + 'content-type': 'application/json', + accept: 'text/html', + 'x-custom': 'custom-value' + }); + + const result = {}; + headers.forEach((value, key) => { + result[key] = value; + }); + + expect(result).to.deep.equal({ + 'content-type': 'application/json', + accept: 'text/html', + 'x-custom': 'custom-value' + }); + }); + + it('should respect thisArg parameter', () => { + const headers = createHeaders({ 'content-type': 'application/json' }); + const context = { value: 'context' }; + + headers.forEach(function iterator() { + expect(this).to.equal(context); + }, context); + }); + }); + }); + + describe('Iterable Interface', () => { + it('should implement entries() iterator', () => { + const headers = createHeaders({ + 'content-type': 'application/json', + accept: 'text/html' + }); + + const entries = Array.from(headers.entries()); + expect(entries).to.deep.include(['content-type', 'application/json']); + expect(entries).to.deep.include(['accept', 'text/html']); + }); + + it('should implement keys() iterator', () => { + const headers = createHeaders({ + 'content-type': 'application/json', + accept: 'text/html' + }); + + const keys = Array.from(headers.keys()); + expect(keys).to.include('content-type'); + expect(keys).to.include('accept'); + }); + + it('should implement values() iterator', () => { + const headers = createHeaders({ + 'content-type': 'application/json', + accept: 'text/html' + }); + + const values = Array.from(headers.values()); + expect(values).to.include('application/json'); + expect(values).to.include('text/html'); + }); + + it('should be iterable with Symbol.iterator', () => { + const headers = createHeaders({ + 'content-type': 'application/json', + accept: 'text/html' + }); + + const entries = Array.from(headers); + expect(entries).to.deep.include(['content-type', 'application/json']); + expect(entries).to.deep.include(['accept', 'text/html']); + }); + }); + + describe('Property Operations', () => { + it('should delete properties in a case-insensitive manner', () => { + const headers = createHeaders({ 'Content-Type': 'application/json' }); + + delete headers['content-type']; + expect('Content-Type' in headers).to.be.false; + }); + + it('should list all header keys with Object.keys', () => { + const headers = createHeaders({ + 'content-type': 'application/json', + accept: 'text/html' + }); + + const keys = Object.keys(headers); + expect(keys).to.include('content-type'); + expect(keys).to.include('accept'); + expect(keys.length).to.equal(2); + }); + }); +}); diff --git a/test/lib/mockRequest.spec.ts b/test/lib/mockRequest.spec.ts index d51834a..7032d61 100644 --- a/test/lib/mockRequest.spec.ts +++ b/test/lib/mockRequest.spec.ts @@ -43,7 +43,7 @@ describe('mockRequest', () => { expect(request.param).to.be.a('function'); }); - it('shoud initialize with default options', () => { + it('should initialize with default options', () => { const request = mockRequest.createRequest(); expect(request.method).to.equal('GET'); expect(request.url).to.equal(''); @@ -54,7 +54,8 @@ describe('mockRequest', () => { expect(request.session).to.be.a('undefined'); expect(request.cookies).to.deep.equal({}); expect(request.signedCookies).to.be.a('undefined'); - expect(request.headers).to.deep.equal({}); + expect(request.headers).to.be.an('object'); + expect(Object.keys(request.headers).length).to.equal(0); expect(request.body).to.deep.equal({}); expect(request.query).to.deep.equal({}); expect(request.files).to.deep.equal({}); @@ -202,9 +203,12 @@ describe('mockRequest', () => { const request = mockRequest.createRequest(options); expect(request.header('KEY1')).to.equal('value1'); expect(request.get('KEY1')).to.equal('value1'); + expect(request.headers.get('KEY1')).to.equal('value1'); expect(request.getHeader('KEY1')).to.equal('value1'); + expect(request.header('KEY2')).to.equal('value2'); expect(request.get('KEY2')).to.equal('value2'); + expect(request.headers.get('KEY2')).to.equal('value2'); expect(request.getHeader('KEY2')).to.equal('value2'); }); @@ -298,6 +302,7 @@ describe('mockRequest', () => { const request = mockRequest.createRequest(options); expect(request.get('key')).to.equal('value'); expect(request.header('key')).to.equal('value'); + expect(request.headers.get('key')).to.equal('value'); expect(request.getHeader('key')).to.equal('value'); }); @@ -311,6 +316,7 @@ describe('mockRequest', () => { const request = mockRequest.createRequest(options); expect(request.get('referrer')).to.equal('value'); expect(request.header('referrer')).to.equal('value'); + expect(request.headers.get('referrer')).to.equal('value'); expect(request.getHeader('referrer')).to.equal('value'); }); @@ -324,6 +330,7 @@ describe('mockRequest', () => { const request = mockRequest.createRequest(options); expect(request.get('referer')).to.equal('value'); expect(request.header('referer')).to.equal('value'); + expect(request.headers.get('referer')).to.equal('value'); expect(request.getHeader('referer')).to.equal('value'); }); @@ -331,6 +338,7 @@ describe('mockRequest', () => { const request = mockRequest.createRequest(); expect(request.get('key')).to.be.a('undefined'); expect(request.header('key')).to.be.a('undefined'); + expect(request.headers.get('key')).to.be.a('undefined'); expect(request.getHeader('key')).to.be.a('undefined'); }); }); @@ -561,6 +569,7 @@ describe('mockRequest', () => { const request = mockRequest.createRequest(); expect(request.get('key')).to.be.a('undefined'); expect(request.header('key')).to.be.a('undefined'); + expect(request.headers.get('key')).to.be.a('undefined'); }); it('should return defaultValue, when not found in params/body/query', () => { @@ -643,6 +652,7 @@ describe('mockRequest', () => { request._setHeadersVariable('key', 'value'); expect(request.get('key')).to.equal('value'); expect(request.header('key')).to.equal('value'); + expect(request.headers.get('key')).to.equal('value'); expect(request.getHeader('key')).to.equal('value'); });