From b3d842fa97a5d1c94f757e1078988fabed281d76 Mon Sep 17 00:00:00 2001 From: William Killerud Date: Tue, 14 May 2024 10:45:25 +0200 Subject: [PATCH] fix: generate types from JSDoc --- .gitignore | 4 +- lib/client.js | 72 ++- lib/http-outgoing.js | 136 +++- lib/resolver.cache.js | 29 +- lib/resolver.content.js | 83 ++- lib/resolver.fallback.js | 19 + lib/resolver.js | 51 +- lib/resolver.manifest.js | 50 +- lib/resource.js | 84 ++- lib/response.js | 16 +- lib/state.js | 15 +- lib/utils.js | 52 +- package.json | 18 +- test/client.register.js | 116 ---- test/http-outgoing.js | 96 --- test/integration.basic.js | 475 -------------- test/resolver.cache.js | 22 - test/resolver.content.js | 537 ---------------- test/resolver.fallback.js | 121 ---- test/resolver.manifest.js | 361 ----------- test/resource.js | 506 --------------- test/response.js | 149 ----- tests/client.register.test.js | 150 +++++ test/client.js => tests/client.test.js | 0 tests/http-outgoing.test.js | 125 ++++ tests/integration.basic.test.js | 543 ++++++++++++++++ tests/resolver.cache.test.js | 27 + tests/resolver.content.test.js | 660 ++++++++++++++++++++ tests/resolver.fallback.test.js | 195 ++++++ tests/resolver.manifest.test.js | 525 ++++++++++++++++ test/resolver.js => tests/resolver.test.js | 19 +- tests/resource.test.js | 694 +++++++++++++++++++++ tests/response.test.js | 213 +++++++ test/state.js => tests/state.test.js | 0 test/utils.js => tests/utils.test.js | 0 tsconfig.json | 16 + tsconfig.test.json | 9 + 37 files changed, 3643 insertions(+), 2545 deletions(-) delete mode 100644 test/client.register.js delete mode 100644 test/http-outgoing.js delete mode 100644 test/integration.basic.js delete mode 100644 test/resolver.cache.js delete mode 100644 test/resolver.content.js delete mode 100644 test/resolver.fallback.js delete mode 100644 test/resolver.manifest.js delete mode 100644 test/resource.js delete mode 100644 test/response.js create mode 100644 tests/client.register.test.js rename test/client.js => tests/client.test.js (100%) create mode 100644 tests/http-outgoing.test.js create mode 100644 tests/integration.basic.test.js create mode 100644 tests/resolver.cache.test.js create mode 100644 tests/resolver.content.test.js create mode 100644 tests/resolver.fallback.test.js create mode 100644 tests/resolver.manifest.test.js rename test/resolver.js => tests/resolver.test.js (51%) create mode 100644 tests/resource.test.js create mode 100644 tests/response.test.js rename test/state.js => tests/state.test.js (100%) rename test/utils.js => tests/utils.test.js (100%) create mode 100644 tsconfig.json create mode 100644 tsconfig.test.json diff --git a/.gitignore b/.gitignore index 0c67303c..cfe6fca0 100644 --- a/.gitignore +++ b/.gitignore @@ -13,5 +13,5 @@ tmp/**/* .idea .idea/**/* coverage -dist/ -.tap \ No newline at end of file +types/ +.tap diff --git a/lib/client.js b/lib/client.js index 8b89f880..b6a47a53 100644 --- a/lib/client.js +++ b/lib/client.js @@ -1,16 +1,17 @@ import EventEmitter from 'events'; -import {uriStrict as validateUriStrict, name as validateName } from'@podium/schemas'; +import { + uriStrict as validateUriStrict, + name as validateName, +} from '@podium/schemas'; import Metrics from '@metrics/client'; import abslog from 'abslog'; import Cache from 'ttl-mem-cache'; import http from 'http'; import https from 'https'; - import Resource from './resource.js'; import State from './state.js'; const inspect = Symbol.for('nodejs.util.inspect.custom'); - const HTTP_AGENT_OPTIONS = { keepAlive: true, maxSockets: 10, @@ -18,24 +19,47 @@ const HTTP_AGENT_OPTIONS = { timeout: 60000, keepAliveMsecs: 30000, }; - const HTTPS_AGENT_OPTIONS = { ...HTTP_AGENT_OPTIONS, maxCachedSessions: 10, }; - const REJECT_UNAUTHORIZED = true; - const HTTP_AGENT = new http.Agent(HTTP_AGENT_OPTIONS); - const HTTPS_AGENT = new https.Agent(HTTPS_AGENT_OPTIONS); - const RETRIES = 4; - const TIMEOUT = 1000; // 1 seconds - const MAX_AGE = Infinity; +/** + * @typedef {import('./resource.js').default} PodiumClientResource + * @typedef {import('./resource.js').PodiumClientResourceOptions} PodiumClientResourceOptions + * @typedef {import('@podium/schemas').PodletManifestSchema} PodletManifest + */ + +/** + * @typedef {object} PodiumClientOptions + * @property {string} name + * @property {import('abslog').AbstractLoggerOptions} [logger] + * @property {number} [retries=4] + * @property {number} [timeout=1000] In milliseconds + * @property {number} [maxAge=Infinity] + * @property {boolean} [rejectUnauthorized=true] + * @property {number} [resolveThreshold] + * @property {number} [resolveMax] + * @property {import('http').Agent} [httpAgent] + * @property {import('https').Agent} [httpsAgent] + */ + +/** + * @typedef {object} RegisterOptions + * @property {string} name + * @property {string} uri To the podlet's `manifest.json` + * @property {number} [retries=4] + * @property {number} [timeout=1000] In milliseconds + * @property {boolean} [throwable=false] + * @property {boolean} [redirectable=false] + */ + export default class PodiumClient extends EventEmitter { #resources; #registry; @@ -43,6 +67,12 @@ export default class PodiumClient extends EventEmitter { #histogram; #options; #state; + + /** + * @constructor + * @param {PodiumClientOptions} options + */ + // @ts-expect-error Deliberate default empty options for better error messages constructor(options = {}) { super(); const log = abslog(options.logger); @@ -69,7 +99,7 @@ export default class PodiumClient extends EventEmitter { resolveThreshold: options.resolveThreshold, resolveMax: options.resolveMax, }); - this.#state.on('state', state => { + this.#state.on('state', (state) => { this.emit('state', state); }); @@ -79,7 +109,7 @@ export default class PodiumClient extends EventEmitter { changefeed: true, ttl: options.maxAge, }); - this.#registry.on('error', error => { + this.#registry.on('error', (error) => { log.error( 'Error emitted by the registry in @podium/client module', error, @@ -91,7 +121,7 @@ export default class PodiumClient extends EventEmitter { }); this.#metrics = new Metrics(); - this.#metrics.on('error', error => { + this.#metrics.on('error', (error) => { log.error( 'Error emitted by metric stream in @podium/client module', error, @@ -99,7 +129,7 @@ export default class PodiumClient extends EventEmitter { }); this[Symbol.iterator] = () => ({ - items: Array.from(this.#resources).map(item => item[1]), + items: Array.from(this.#resources).map((item) => item[1]), next: function next() { return { done: this.items.length === 0, @@ -128,7 +158,13 @@ export default class PodiumClient extends EventEmitter { return this.#state.status; } - register(options = {}) { + /** + * Register a podlet so you can fetch its contents later. + * + * @param {RegisterOptions} options + * @returns {PodiumClientResource} + */ + register(options) { if (validateName(options.name).error) throw new Error( `The value, "${options.name}", for the required argument "name" on the .register() method is not defined or not valid.`, @@ -188,7 +224,9 @@ export default class PodiumClient extends EventEmitter { // Don't return this await Promise.all( - Array.from(this.#resources).map(resource => resource[1].refresh()), + Array.from(this.#resources).map((resource) => + resource[1].refresh(), + ), ); end(); @@ -204,4 +242,4 @@ export default class PodiumClient extends EventEmitter { get [Symbol.toStringTag]() { return 'PodiumClient'; } -}; +} diff --git a/lib/http-outgoing.js b/lib/http-outgoing.js index a81b07bc..187fdce2 100644 --- a/lib/http-outgoing.js +++ b/lib/http-outgoing.js @@ -3,26 +3,84 @@ import { PassThrough } from 'stream'; import assert from 'assert'; +/** + * @typedef {object} PodiumClientHttpOutgoingOptions + * @property {string} name + * @property {string} uri To the podlet's `manifest.json` + * @property {number} timeout In milliseconds + * @property {number} maxAge + * @property {number} [retries=4] + * @property {boolean} [throwable=false] + * @property {boolean} [redirectable=false] + * @property {boolean} [rejectUnauthorized=true] + * @property {import('http').Agent} [httpAgent] + * @property {import('https').Agent} [httpsAgent] + */ + +/** + * @typedef {object} PodiumClientResourceOptions + * @property {string} [pathname] + * @property {import('http').OutgoingHttpHeaders} [headers] + * @property {object} [query] + */ + +/** + * @typedef {object} PodiumRedirect + * @property {number} statusCode; + * @property {string} location; + */ + +/** + * @typedef {object} PodletProxySchema + * @property {string} target + * @property {string} name + */ + +/** + * @typedef {object} PodletManifest Similar to the schema's manifest, but with instances of AssetCss and AssetJs from `@podium/utils` and default values. + * @property {string} name + * @property {string} version + * @property {string} content + * @property {string} fallback + * @property {Array} js + * @property {Array} css + * @property {Record | Array} proxy + * @property {string} team + */ + export default class PodletClientHttpOutgoing extends PassThrough { #rejectUnauthorized; #killRecursions; #killThreshold; #redirectable; #reqOptions; - #isFallback; + #isFallback = false; #throwable; + /** @type {PodletManifest} */ #manifest; #incoming; - #redirect; + /** @type {null | PodiumRedirect} */ + #redirect = null; #timeout; #success; #headers; #maxAge; + /** @type {'empty' | 'fresh' | 'cached' | 'stale'} */ #status; #name; - #uri; - constructor( - { + #uri; + + /** + * @constructor + * @param {PodiumClientHttpOutgoingOptions} options + * @param {PodiumClientResourceOptions} [reqOptions] + * @param {import('@podium/utils').HttpIncoming} [incoming] + */ + // @ts-expect-error Deliberate default empty options for better error messages + constructor(options = {}, reqOptions, incoming) { + super(); + + const { rejectUnauthorized = true, throwable = false, redirectable = false, @@ -31,11 +89,7 @@ export default class PodletClientHttpOutgoing extends PassThrough { maxAge, name = '', uri, - } = {}, - reqOptions, - incoming, - ) { - super(); + } = options; assert( uri, @@ -45,7 +99,6 @@ export default class PodletClientHttpOutgoing extends PassThrough { // If requests to https sites should reject on unsigned sertificates this.#rejectUnauthorized = rejectUnauthorized; - // A HttpIncoming object this.#incoming = incoming; // Kill switch for breaking the recursive promise chain @@ -67,6 +120,7 @@ export default class PodletClientHttpOutgoing extends PassThrough { // Manifest which is either retrieved from the registry or // remote podlet (in other words its not saved in registry yet) this.#manifest = { + // @ts-expect-error Internal property _fallback: '', }; @@ -82,14 +136,6 @@ export default class PodletClientHttpOutgoing extends PassThrough { // How long the manifest should be cached before refetched this.#maxAge = maxAge; - // What status the manifest is in. This is used to tell what actions need to - // be performed throughout the resolving process to complete a request. - // - // The different statuses can be: - // "empty" - there is no manifest available - we are in process of fetching it - // "fresh" - the manifest has been fetched but is not stored in cache yet - // "cached" - the manifest was retrieved from cache - // "stale" - the manifest is outdated, a new manifest needs to be fetched this.#status = 'empty'; // Name of the resource (name given to client) @@ -100,13 +146,6 @@ export default class PodletClientHttpOutgoing extends PassThrough { // Whether the user can handle redirects manually. this.#redirectable = redirectable; - - // When redirectable is true, this object should be populated with redirect information - // such that a user can perform manual redirection - this.#redirect = null; - - // When isfallback is true, content fetch has failed and fallback will be served instead - this.#isFallback = false; } get rejectUnauthorized() { @@ -130,10 +169,12 @@ export default class PodletClientHttpOutgoing extends PassThrough { } get fallback() { + // @ts-expect-error Internal property return this.#manifest._fallback; } set fallback(value) { + // @ts-expect-error Internal property this.#manifest._fallback = value; } @@ -169,6 +210,16 @@ export default class PodletClientHttpOutgoing extends PassThrough { this.#maxAge = value; } + /** + * What status the manifest is in. This is used to tell what actions need to + * be performed throughout the resolving process to complete a request. + * + * The different statuses can be: + * - `"empty"` - there is no manifest available - we are in process of fetching it + * - `"fresh"` - the manifest has been fetched but is not stored in cache yet + * - `"cached"` - the manifest was retrieved from cache + * - `"stale"` - the manifest is outdated, a new manifest needs to be fetched + */ get status() { return this.#status; } @@ -193,18 +244,33 @@ export default class PodletClientHttpOutgoing extends PassThrough { return this.#manifest.content; } + /** + * Kill switch for breaking the recursive promise chain in case it is never able to completely resolve. + * This is true if the number of recursions matches the threshold. + */ get kill() { return this.#killRecursions === this.#killThreshold; } + /** + * The number of recursions before the request should be {@link kill}ed + */ get recursions() { return this.#killRecursions; } + /** + * Set the number of recursions before the request should be {@link kill}ed + */ set recursions(value) { this.#killRecursions = value; } + /** + * When {@link redirectable} is `true` this is populated with redirect information so you can send a redirect response to the browser from your layout. + * + * @see https://podium-lib.io/docs/layout/handling_redirects + */ get redirect() { return this.#redirect; } @@ -213,6 +279,11 @@ export default class PodletClientHttpOutgoing extends PassThrough { this.#redirect = value; } + /** + * Whether the podlet can signal redirects to the layout. + * + * @see https://podium-lib.io/docs/layout/handling_redirects + */ get redirectable() { return this.#redirectable; } @@ -222,17 +293,22 @@ export default class PodletClientHttpOutgoing extends PassThrough { } /** - * Boolean getter that indicates whether the client is responding with a content or fallback payload. + * True if the client has returned the podlet's fallback. + * * @example - * ``` + * + * ```js * if (outgoing.isFallback) console.log("Fallback!"); * ``` + * + * @see https://podium-lib.io/docs/podlet/fallbacks */ get isFallback() { - return this.#isFallback; + return this.#isFallback; } pushFallback() { + // @ts-expect-error Internal property this.push(this.#manifest._fallback); this.push(null); this.#isFallback = true; @@ -241,4 +317,4 @@ export default class PodletClientHttpOutgoing extends PassThrough { get [Symbol.toStringTag]() { return 'PodletClientHttpOutgoing'; } -}; +} diff --git a/lib/resolver.cache.js b/lib/resolver.cache.js index edfcd328..5a7f374b 100644 --- a/lib/resolver.cache.js +++ b/lib/resolver.cache.js @@ -5,9 +5,20 @@ import clonedeep from 'lodash.clonedeep'; import abslog from 'abslog'; import assert from 'assert'; +/** + * @typedef {object} PodletClientCacheResolverOptions + * @property {import('abslog').AbstractLoggerOptions} [logger] + */ + export default class PodletClientCacheResolver { #registry; #log; + + /** + * @constructor + * @param {import('ttl-mem-cache').default} registry + * @param {PodletClientCacheResolverOptions} options + */ constructor(registry, options = {}) { assert( registry, @@ -17,8 +28,14 @@ export default class PodletClientCacheResolver { this.#log = abslog(options.logger); } + /** + * Loads the podlet's manifest from cache if not stale + * + * @param {import('./http-outgoing.js').default} outgoing + * @returns {Promise} + */ load(outgoing) { - return new Promise(resolve => { + return new Promise((resolve) => { if (outgoing.status !== 'stale') { const cached = this.#registry.get(outgoing.name); if (cached) { @@ -33,8 +50,14 @@ export default class PodletClientCacheResolver { }); } + /** + * Saves the podlet's manifest to the cache + * + * @param {import('./http-outgoing.js').default} outgoing + * @returns {Promise} + */ save(outgoing) { - return new Promise(resolve => { + return new Promise((resolve) => { if (outgoing.status === 'fresh') { this.#registry.set( outgoing.name, @@ -55,4 +78,4 @@ export default class PodletClientCacheResolver { get [Symbol.toStringTag]() { return 'PodletClientCacheResolver'; } -}; +} diff --git a/lib/resolver.content.js b/lib/resolver.content.js index 096d2454..f7ec7070 100644 --- a/lib/resolver.content.js +++ b/lib/resolver.content.js @@ -21,11 +21,24 @@ const pkg = JSON.parse(pkgJson); const UA_STRING = `${pkg.name} ${pkg.version}`; +/** + * @typedef {object} PodletClientContentResolverOptions + * @property {string} clientName + * @property {import('./http.js').default} [http] + * @property {import('abslog').AbstractLoggerOptions} [logger] + */ + export default class PodletClientContentResolver { #log; #metrics; #histogram; #http; + + /** + * @constructor + * @param {PodletClientContentResolverOptions} options + */ + // @ts-expect-error Deliberate default empty options for better error messages constructor(options = {}) { this.#http = options.http || new HTTP(); const name = options.clientName; @@ -54,6 +67,12 @@ export default class PodletClientContentResolver { return this.#metrics; } + /** + * Resolves/fetches the podlet's content. + * + * @param {import('./http-outgoing.js').default} outgoing + * @returns {Promise} + */ async resolve(outgoing) { if (outgoing.kill && outgoing.throwable) { this.#log.warn( @@ -71,12 +90,12 @@ export default class PodletClientContentResolver { outgoing.success = true; outgoing.pushFallback(); outgoing.emit( - 'beforeStream', - new Response({ - js: utils.filterAssets("fallback", outgoing.manifest.js), - css: utils.filterAssets("fallback", outgoing.manifest.css), - }), - ); + 'beforeStream', + new Response({ + js: utils.filterAssets('fallback', outgoing.manifest.js), + css: utils.filterAssets('fallback', outgoing.manifest.css), + }), + ); return outgoing; } @@ -94,12 +113,12 @@ export default class PodletClientContentResolver { outgoing.success = true; outgoing.pushFallback(); outgoing.emit( - 'beforeStream', - new Response({ - js: utils.filterAssets("fallback", outgoing.manifest.js), - css: utils.filterAssets("fallback", outgoing.manifest.css), - }), - ); + 'beforeStream', + new Response({ + js: utils.filterAssets('fallback', outgoing.manifest.js), + css: utils.filterAssets('fallback', outgoing.manifest.css), + }), + ); return outgoing; } @@ -113,7 +132,7 @@ export default class PodletClientContentResolver { const uri = putils.uriBuilder( outgoing.reqOptions.pathname, outgoing.contentUri, - ) + ); const reqOptions = { rejectUnauthorized: outgoing.rejectUnauthorized, @@ -183,11 +202,17 @@ export default class PodletClientContentResolver { outgoing.success = true; outgoing.pushFallback(); outgoing.emit( - 'beforeStream', - new Response({ - js: utils.filterAssets("fallback", outgoing.manifest.js), - css: utils.filterAssets("fallback", outgoing.manifest.css), - }), + 'beforeStream', + new Response({ + js: utils.filterAssets( + 'fallback', + outgoing.manifest.js, + ), + css: utils.filterAssets( + 'fallback', + outgoing.manifest.css, + ), + }), ); // Body must be consumed; https://github.com/nodejs/undici/issues/583#issuecomment-855384858 @@ -227,6 +252,7 @@ export default class PodletClientContentResolver { if (outgoing.redirectable && statusCode >= 300) { outgoing.redirect = { statusCode, + // @ts-expect-error TODO: look into what happens if the podlet returns more than one location header location: hdrs && hdrs.location, }; } @@ -235,8 +261,8 @@ export default class PodletClientContentResolver { 'beforeStream', new Response({ headers: outgoing.headers, - js: utils.filterAssets("content", outgoing.manifest.js), - css: utils.filterAssets("content", outgoing.manifest.css), + js: utils.filterAssets('content', outgoing.manifest.js), + css: utils.filterAssets('content', outgoing.manifest.css), redirect: outgoing.redirect, }), ); @@ -260,10 +286,7 @@ export default class PodletClientContentResolver { this.#log.warn( `could not create network connection to remote resource when trying to request content - resource: ${outgoing.name} - url: ${uri}`, ); - throw badGateway( - `Error reading content at ${uri}`, - error, - ); + throw badGateway(`Error reading content at ${uri}`, error); } timer({ @@ -280,12 +303,12 @@ export default class PodletClientContentResolver { outgoing.pushFallback(); outgoing.emit( - 'beforeStream', - new Response({ - js: utils.filterAssets("fallback", outgoing.manifest.js), - css: utils.filterAssets("fallback", outgoing.manifest.css), - }), - ); + 'beforeStream', + new Response({ + js: utils.filterAssets('fallback', outgoing.manifest.js), + css: utils.filterAssets('fallback', outgoing.manifest.css), + }), + ); return outgoing; } diff --git a/lib/resolver.fallback.js b/lib/resolver.fallback.js index da77f5c1..eceaac7a 100644 --- a/lib/resolver.fallback.js +++ b/lib/resolver.fallback.js @@ -17,11 +17,24 @@ const pkg = JSON.parse(pkgJson); const UA_STRING = `${pkg.name} ${pkg.version}`; +/** + * @typedef {object} PodletClientFallbackResolverOptions + * @property {string} clientName + * @property {import('./http.js').default} [http] + * @property {import('abslog').AbstractLoggerOptions} [logger] + */ + export default class PodletClientFallbackResolver { #log; #metrics; #histogram; #http; + + /** + * @constructor + * @param {PodletClientFallbackResolverOptions} options + */ + // @ts-expect-error Deliberate default empty options for better error messages constructor(options = {}) { this.#http = options.http || new HTTP(); const name = options.clientName; @@ -50,6 +63,12 @@ export default class PodletClientFallbackResolver { return this.#metrics; } + /** + * Resolves/fetches the podlet's fallback. + * + * @param {import('./http-outgoing.js').default} outgoing + * @returns {Promise} + */ async resolve(outgoing) { if (outgoing.status === 'cached') { return outgoing; diff --git a/lib/resolver.js b/lib/resolver.js index 5adca31d..32a4d545 100644 --- a/lib/resolver.js +++ b/lib/resolver.js @@ -7,12 +7,25 @@ import Fallback from './resolver.fallback.js'; import Content from './resolver.content.js'; import Cache from './resolver.cache.js'; +/** + * @typedef {object} PodletClientResolverOptions + * @property {string} clientName + * @property {import('abslog').AbstractLoggerOptions} [logger] + */ + export default class PodletClientResolver { #cache; #manifest; #fallback; #content; #metrics; + + /** + * @constructor + * @param {import('ttl-mem-cache').default} registry + * @param {PodletClientResolverOptions} options + */ + // @ts-expect-error Deliberate default empty options for better error messages constructor(registry, options = {}) { assert( registry, @@ -22,12 +35,16 @@ export default class PodletClientResolver { const log = abslog(options.logger); const http = new HTTP(); this.#cache = new Cache(registry, options); - this.#manifest = new Manifest({ ...options, http }); + this.#manifest = new Manifest({ + clientName: options.clientName, + logger: options.logger, + http, + }); this.#fallback = new Fallback({ ...options, http }); this.#content = new Content({ ...options, http }); this.#metrics = new Metrics(); - this.#metrics.on('error', error => { + this.#metrics.on('error', (error) => { log.error( 'Error emitted by metric stream in @podium/client module', error, @@ -43,14 +60,20 @@ export default class PodletClientResolver { return this.#metrics; } + /** + * Resolve the podlet's manifest, fallback and content + * + * @param {import('./http-outgoing.js').default} outgoing + * @returns {Promise} + */ resolve(outgoing) { return this.#cache .load(outgoing) - .then(obj => this.#manifest.resolve(obj)) - .then(obj => this.#fallback.resolve(obj)) - .then(obj => this.#content.resolve(obj)) - .then(obj => this.#cache.save(obj)) - .then(obj => { + .then((obj) => this.#manifest.resolve(obj)) + .then((obj) => this.#fallback.resolve(obj)) + .then((obj) => this.#content.resolve(obj)) + .then((obj) => this.#cache.save(obj)) + .then((obj) => { if (obj.success) { return obj; } @@ -58,15 +81,21 @@ export default class PodletClientResolver { }); } + /** + * Refresh the podlet's cached manifest and fallback + * + * @param {import('./http-outgoing.js').default} outgoing + * @returns {Promise} `true` if successful + */ refresh(outgoing) { return this.#manifest .resolve(outgoing) - .then(obj => this.#fallback.resolve(obj)) - .then(obj => this.#cache.save(obj)) - .then(obj => !!obj.manifest.name); + .then((obj) => this.#fallback.resolve(obj)) + .then((obj) => this.#cache.save(obj)) + .then((obj) => !!obj.manifest.name); } get [Symbol.toStringTag]() { return 'PodletClientResolver'; } -}; +} diff --git a/lib/resolver.manifest.js b/lib/resolver.manifest.js index 6ca9231f..33d4f1d3 100644 --- a/lib/resolver.manifest.js +++ b/lib/resolver.manifest.js @@ -20,11 +20,24 @@ const pkg = JSON.parse(pkgJson); const UA_STRING = `${pkg.name} ${pkg.version}`; +/** + * @typedef {object} PodletClientManifestResolverOptions + * @property {string} clientName + * @property {import('abslog').AbstractLoggerOptions} [logger] + * @property {import('./http.js').default} [http] + */ + export default class PodletClientManifestResolver { #log; #metrics; #histogram; #http; + + /** + * @constructor + * @param {PodletClientManifestResolverOptions} options + */ + // @ts-expect-error Deliberate default empty options for better error messages constructor(options = {}) { this.#http = options.http || new HTTP(); const name = options.clientName; @@ -53,6 +66,10 @@ export default class PodletClientManifestResolver { return this.#metrics; } + /** + * @param {import('./http-outgoing.js').default} outgoing + * @returns {Promise} + */ async resolve(outgoing) { if (outgoing.status === 'cached') { return outgoing; @@ -81,11 +98,17 @@ export default class PodletClientManifestResolver { ); try { + const response = await this.#http.request( + outgoing.manifestUri, + reqOptions, + ); const { statusCode, headers: hdrs, body, - } = await this.#http.request(outgoing.manifestUri, reqOptions); + } = /** @type {{ statusCode: number; headers: import("http").IncomingHttpHeaders; body: import("undici").BodyMixin; }} */ ( + response + ); // Remote responds but with an http error code const resError = statusCode !== 200; @@ -105,7 +128,11 @@ export default class PodletClientManifestResolver { return outgoing; } - const manifest = validateManifest(await body.json()); + const manifest = validateManifest( + /** @type {import("@podium/schemas").PodletManifestSchema} */ ( + await body.json() + ), + ); // Manifest validation error if (manifest.error) { @@ -158,6 +185,7 @@ export default class PodletClientManifestResolver { ); // Construct css and js objects with absolute URIs + // @ts-expect-error We assign here what will end up as PodletManifest as defined in http-outgoing.js manifest.value.css = manifest.value.css.map((obj) => { obj.value = utils.uriRelativeToAbsolute( obj.value, @@ -166,6 +194,7 @@ export default class PodletClientManifestResolver { return new utils.AssetCss(obj); }); + // @ts-expect-error We assign here what will end up as PodletManifest as defined in http-outgoing.js manifest.value.js = manifest.value.js.map((obj) => { obj.value = utils.uriRelativeToAbsolute( obj.value, @@ -179,9 +208,9 @@ export default class PodletClientManifestResolver { If .proxy is and Array, the podlet are of version 6 or newer. The Array is then an Array of proxy Objects ({ name: 'foo', target: '/' }) which is the new structure - wanted from version 6 and onwards so leave this structure untouched. + wanted from version 6 and onwards so leave this structure untouched. - If .proxy is an Object, the podlet are of version 5 or older where the key of the + If .proxy is an Object, the podlet are of version 5 or older where the key of the Object is the key of the target. If so, convert the structure to the new structure consisting of an Array of proxy Objects. @@ -191,12 +220,12 @@ export default class PodletClientManifestResolver { if (Array.isArray(manifest.value.proxy)) { // Build absolute proxy URIs manifest.value.proxy = manifest.value.proxy.map((item) => ({ - target: utils.uriRelativeToAbsolute( - item.target, - outgoing.manifestUri, - ), - name: item.name, - })); + target: utils.uriRelativeToAbsolute( + item.target, + outgoing.manifestUri, + ), + name: item.name, + })); } else { const proxies = []; // Build absolute proxy URIs @@ -216,6 +245,7 @@ export default class PodletClientManifestResolver { END: Proxy backwards compabillity check and handling */ + // @ts-expect-error We map to AssetCss and AssetJs above outgoing.manifest = manifest.value; outgoing.status = 'fresh'; diff --git a/lib/resource.js b/lib/resource.js index f84a1770..71e34cb9 100644 --- a/lib/resource.js +++ b/lib/resource.js @@ -11,11 +11,35 @@ import * as utils from './utils.js'; const inspect = Symbol.for('nodejs.util.inspect.custom'); +/** + * @typedef {object} PodiumClientResourceOptions + * @property {import('abslog').AbstractLoggerOptions} [logger] + * @property {string} clientName + * @property {string} name + * @property {string} uri To the podlet's `manifest.json` + * @property {number} timeout In milliseconds + * @property {number} maxAge + * @property {number} [retries] + * @property {boolean} [throwable] + * @property {boolean} [redirectable] + * @property {boolean} [rejectUnauthorized] + * @property {import('http').Agent} [httpAgent] + * @property {import('https').Agent} [httpsAgent] + */ + export default class PodiumClientResource { #resolver; #options; #metrics; #state; + + /** + * @constructor + * @param {import('ttl-mem-cache').default} registry + * @param {import('./state.js').default} state + * @param {PodiumClientResourceOptions} options + */ + // @ts-expect-error Deliberate for better error messages constructor(registry, state, options = {}) { assert( registry, @@ -34,7 +58,7 @@ export default class PodiumClientResource { this.#metrics = new Metrics(); this.#state = state; - this.#metrics.on('error', error => { + this.#metrics.on('error', (error) => { log.error( 'Error emitted by metric stream in @podium/client module', error, @@ -56,20 +80,29 @@ export default class PodiumClientResource { return this.#options.uri; } - async fetch(incoming = {}, reqOptions = {}) { - if (!utils.validateIncoming(incoming)) throw new TypeError('you must pass an instance of "HttpIncoming" as the first argument to the .fetch() method'); + /** + * Fetch the podlet's content, or fallback if the podlet is unavailable. + * + * @param {import('@podium/utils').HttpIncoming} incoming + * @param {import('./http-outgoing.js').PodiumClientResourceOptions} [reqOptions={}] + * @returns {Promise} + */ + async fetch(incoming, reqOptions = {}) { + if (!utils.validateIncoming(incoming)) + throw new TypeError( + 'you must pass an instance of "HttpIncoming" as the first argument to the .fetch() method', + ); const outgoing = new HttpOutgoing(this.#options, reqOptions, incoming); this.#state.setInitializingState(); - const { manifest, headers, redirect, isFallback } = await this.#resolver.resolve( - outgoing, - ); + const { manifest, headers, redirect, isFallback } = + await this.#resolver.resolve(outgoing); const chunks = []; // eslint-disable-next-line no-restricted-syntax for await (const chunk of outgoing) { - chunks.push(chunk) + chunks.push(chunk); } const content = !outgoing.redirect @@ -79,24 +112,47 @@ export default class PodiumClientResource { return new Response({ headers, content, - css: utils.filterAssets(isFallback ? "fallback" : "content", manifest.css), - js: utils.filterAssets(isFallback ? "fallback" : "content", manifest.js), + css: utils.filterAssets( + isFallback ? 'fallback' : 'content', + manifest.css, + ), + js: utils.filterAssets( + isFallback ? 'fallback' : 'content', + manifest.js, + ), redirect, }); } - stream(incoming = {}, reqOptions = {}) { - if (!utils.validateIncoming(incoming)) throw new TypeError('you must pass an instance of "HttpIncoming" as the first argument to the .stream() method'); + /** + * Stream the podlet's content, or fallback if the podlet is unavailable. + * + * @param {import('@podium/utils').HttpIncoming} incoming + * @param {import('./http-outgoing.js').PodiumClientResourceOptions} [reqOptions={}] + * @returns {import('./http-outgoing.js').default} + */ + stream(incoming, reqOptions = {}) { + if (!utils.validateIncoming(incoming)) + throw new TypeError( + 'you must pass an instance of "HttpIncoming" as the first argument to the .stream() method', + ); const outgoing = new HttpOutgoing(this.#options, reqOptions, incoming); this.#state.setInitializingState(); this.#resolver.resolve(outgoing); return outgoing; } - refresh(incoming = {}, reqOptions = {}) { + /** + * Refresh the podlet's manifest and fallback in the cache. + * + * @param {import('@podium/utils').HttpIncoming} [incoming] + * @param {import('./http-outgoing.js').PodiumClientResourceOptions} [reqOptions={}] + * @returns {Promise} `true` if succesful + */ + refresh(incoming, reqOptions = {}) { const outgoing = new HttpOutgoing(this.#options, reqOptions, incoming); this.#state.setInitializingState(); - return this.#resolver.refresh(outgoing).then(obj => obj); + return this.#resolver.refresh(outgoing).then((obj) => obj); } [inspect]() { @@ -110,4 +166,4 @@ export default class PodiumClientResource { get [Symbol.toStringTag]() { return 'PodiumClientResource'; } -}; +} diff --git a/lib/response.js b/lib/response.js index 879a1ca7..4fe15ef3 100644 --- a/lib/response.js +++ b/lib/response.js @@ -1,11 +1,25 @@ const inspect = Symbol.for('nodejs.util.inspect.custom'); +/** + * @typedef {object} PodiumClientResponseOptions + * @property {string} [content] + * @property {object} [headers] + * @property {Array} [js] + * @property {Array} [css] + * @property {import('./http-outgoing.js').PodiumRedirect | null} [redirect] + */ + export default class PodiumClientResponse { #redirect; #content; #headers; #css; #js; + + /** + * @constructor + * @param {PodiumClientResponseOptions} options + */ constructor({ content = '', headers = {}, @@ -71,4 +85,4 @@ export default class PodiumClientResponse { get [Symbol.toStringTag]() { return 'PodiumClientResponse'; } -}; +} diff --git a/lib/state.js b/lib/state.js index 462577f8..984e8d2a 100644 --- a/lib/state.js +++ b/lib/state.js @@ -2,12 +2,25 @@ import EventEmitter from 'events'; const inspect = Symbol.for('nodejs.util.inspect.custom'); +/** + * @typedef {object} PodiumClientStateOptions + * @property {number} [resolveThreshold=10000] + * @property {number} [resolveMax=240000] + */ + export default class PodiumClientState extends EventEmitter { + /** @type {NodeJS.Timeout | undefined} */ #thresholdTimer; #threshold; + /** @type {NodeJS.Timeout | undefined} */ #maxTimer; + /** @type {"instantiated" | "stable" | "initializing" | "unhealthy" | "unstable"} */ #state; #max; + + /** + * @param {PodiumClientStateOptions} [options] + */ constructor({ resolveThreshold = 10 * 1000, resolveMax = 4 * 60 * 1000, @@ -113,4 +126,4 @@ export default class PodiumClientState extends EventEmitter { get [Symbol.toStringTag]() { return 'PodiumClientState'; } -}; +} diff --git a/lib/utils.js b/lib/utils.js index 1d8908b3..6ff091b9 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -1,14 +1,12 @@ /** - * Checks if a header Oject has a header. - * Will return true if the header exist and are not an empty - * String or a String of whitespace. + * Checks if a header object has a header. + * Will return true if the header exist and is not an empty + * string or a string of whitespace. * - * @param {Object} headers A headers object - * @param {String} header A header value - * - * @returns {Boolean} + * @param {object} headers + * @param {string} header + * @returns {boolean} */ - export const isHeaderDefined = (headers, header) => { if (headers[header] === undefined || headers[header].trim() === '') { return false; @@ -21,31 +19,24 @@ export const isHeaderDefined = (headers, header) => { * the changelog event object emitted from the internal * cache registry. * - * @param {Object} item A changelog event object - * - * @returns {Boolean} + * @param {object} item A changelog event object + * @returns {boolean} */ - -export const hasManifestChange = item => { +export const hasManifestChange = (item) => { const oldVersion = item.oldVal ? item.oldVal.version : ''; const newVersion = item.newVal ? item.newVal.version : ''; return oldVersion !== newVersion; }; - /** - * Check if a value is a HttpIncoming object or not. If not, it + * Check if a value is a Podium HttpIncoming object or not. If not, it * assume the incoming value is a context * - * @param {Object} incoming A object - * - * @returns {HttpIncoming} - */ -export const validateIncoming = (incoming = {}) => (Object.prototype.toString.call(incoming) === '[object PodiumHttpIncoming]'); - -/** - * @typedef {import("@podium/utils").AssetCss | import("@podium/utils").AssetJs} Asset + * @param {object} incoming + * @returns {boolean} */ +export const validateIncoming = (incoming = {}) => + Object.prototype.toString.call(incoming) === '[object PodiumHttpIncoming]'; /** * Filter assets array based on scope. @@ -53,9 +44,10 @@ export const validateIncoming = (incoming = {}) => (Object.prototype.toString.ca * If scope property is set to "all", asset will be included. * If scope is set to "content" and asset scope property is set to "fallback", asset will not be included * If scope is set to "fallback" and asset scope property is set to "content", asset will not be included + * @template {import("@podium/utils").AssetCss | import("@podium/utils").AssetJs} T[] * @param {"content" | "fallback" | "all"} scope - * @param {Asset[]} assets - * @returns {Asset[]} + * @param {T[]} assets + * @returns {T[]} * * @example * ``` @@ -69,7 +61,13 @@ export const filterAssets = (scope, assets) => { // if undefined or null, passthrough if (!assets) return assets; // if a non array value is given, throw - if (!Array.isArray(assets)) throw new TypeError(`Asset definition must be of type array. Got ${typeof assets}`); + if (!Array.isArray(assets)) + throw new TypeError( + `Asset definition must be of type array. Got ${typeof assets}`, + ); // filter the array of asset definitions to matchin scope or anything with all. Treat no scope the same as "all" for backwards compatibility. - return assets.filter(asset => !asset.scope || asset.scope === scope || asset.scope === "all"); + return assets.filter( + (asset) => + !asset.scope || asset.scope === scope || asset.scope === 'all', + ); }; diff --git a/package.json b/package.json index ccae9af7..b3057c0c 100644 --- a/package.json +++ b/package.json @@ -21,23 +21,23 @@ "files": [ "package.json", "CHANGELOG.md", - "client.d.ts", "README.md", "LICENSE", - "dist", - "lib" + "lib", + "types" ], "main": "./lib/client.js", - "types": "client.d.ts", + "types": "./types/client.d.ts", "scripts": { "lint": "eslint .", "lint:fix": "eslint --fix .", - "test": "tap --disable-coverage --allow-empty-coverage" + "test": "tap tests/*.js --disable-coverage --allow-empty-coverage && tsc --project tsconfig.test.json", + "types": "tsc --declaration --emitDeclarationOnly" }, "dependencies": { "@hapi/boom": "10.0.1", "@metrics/client": "2.5.2", - "@podium/schemas": "5.0.1", + "@podium/schemas": "5.0.2", "@podium/utils": "5.0.6", "abslog": "2.4.4", "http-cache-semantics": "^4.0.3", @@ -46,14 +46,15 @@ "undici": "6.16.1" }, "devDependencies": { + "@babel/eslint-parser": "7.24.5", "@podium/test-utils": "2.5.2", "@semantic-release/changelog": "6.0.3", "@semantic-release/git": "10.0.1", - "@babel/eslint-parser": "7.24.5", "@semantic-release/github": "10.0.3", "@semantic-release/npm": "12.0.0", "@semantic-release/release-notes-generator": "13.0.0", "@sinonjs/fake-timers": "11.2.2", + "@types/readable-stream": "4.0.14", "benchmark": "2.1.4", "eslint": "8.57.0", "eslint-config-airbnb-base": "15.0.0", @@ -66,6 +67,7 @@ "is-stream": "4.0.1", "prettier": "3.2.5", "semantic-release": "23.0.8", - "tap": "18.7.2" + "tap": "18.7.2", + "typescript": "5.4.5" } } diff --git a/test/client.register.js b/test/client.register.js deleted file mode 100644 index 8103b310..00000000 --- a/test/client.register.js +++ /dev/null @@ -1,116 +0,0 @@ -import tap from 'tap'; -import Resource from '../lib/resource.js'; -import Client from '../lib/client.js'; - -/** - * .register() - */ - -tap.test('client.register() - call with a valid value for "options.uri" - should return a "Resources" object', t => { - const client = new Client({ name: 'podiumClient' }); - const resource = client.register({ - uri: 'http://example-a.org', - name: 'example', - }); - t.ok(resource instanceof Resource); - t.end(); -}); - -tap.test('client.register() - call with no options - should throw', t => { - const client = new Client({ name: 'podiumClient' }); - t.throws(() => { - client.register(); - }, 'The value, "undefined", for the required argument "name" on the .register() method is not defined or not valid.'); - t.end(); -}); - -tap.test('client.register() - call with missing value for "options.uri" - should throw', t => { - const client = new Client({ name: 'podiumClient' }); - - t.throws(() => { - client.register({ name: 'example' }); - }, 'The value, "undefined", for the required argument "uri" on the .register() method is not defined or not valid.'); - t.end(); -}); - -tap.test('client.register() - call with a invalid value for "options.uri" - should throw', t => { - const client = new Client({ name: 'podiumClient' }); - - t.throws(() => { - client.register({ uri: '/wrong', name: 'someName' }); - }, 'The value, "/wrong", for the required argument "uri" on the .register() method is not defined or not valid.'); - t.end(); -}); - -tap.test('client.register() - call with a invalid value for "options.name" - should throw', t => { - const client = new Client({ name: 'podiumClient' }); - - t.throws(() => { - client.register({ uri: 'http://example-a.org', name: 'some name' }); - }, 'The value, "some name", for the required argument "name" on the .register() method is not defined or not valid.'); - t.end(); -}); - -tap.test('client.register() - call with missing value for "options.name" - should throw', t => { - const client = new Client({ name: 'podiumClient' }); - - t.throws(() => { - client.register({ uri: 'http://example-a.org' }); - }, 'The value, "undefined", for the required argument "name" on the .register() method is not defined or not valid.'); - t.end(); -}); - -tap.test('client.register() - call duplicate names - should throw', t => { - const client = new Client({ name: 'podiumClient' }); - client.register({ uri: 'http://example-a.org', name: 'someName' }); - - t.throws(() => { - client.register({ uri: 'http://example-a.org', name: 'someName' }); - }, 'Resource with the name "someName" has already been registered.'); - t.end(); -}); - -tap.test('client.register() - register resources - should set resource as property of client object', t => { - const client = new Client({ name: 'podiumClient' }); - const a = client.register({ - uri: 'http://example-a.org', - name: 'exampleA', - }); - const b = client.register({ - uri: 'http://example-b.org', - name: 'exampleB', - }); - t.ok(client.exampleA); - t.ok(client.exampleB); - t.equal(a, client.exampleA); - t.equal(b, client.exampleB); - t.end(); -}); - -tap.test('client.register() - register resources - should be possible to iterate over resources set on client object', t => { - const client = new Client({ name: 'podiumClient' }); - const a = client.register({ - uri: 'http://example-a.org', - name: 'exampleA', - }); - const b = client.register({ - uri: 'http://example-b.org', - name: 'exampleB', - }); - - t.same([a, b], Array.from(client)); - t.end(); -}); - -tap.test('client.register() - try to manually set register resource - should throw', t => { - const client = new Client({ name: 'podiumClient' }); - client.register({ - uri: 'http://example-a.org', - name: 'exampleA', - }); - - t.throws(() => { - client.exampleA = 'foo'; - }, 'Cannot set read-only property.'); - t.end(); -}); diff --git a/test/http-outgoing.js b/test/http-outgoing.js deleted file mode 100644 index 64ddba10..00000000 --- a/test/http-outgoing.js +++ /dev/null @@ -1,96 +0,0 @@ -import tap from 'tap'; -import { destinationBufferStream } from '@podium/test-utils'; -import { PassThrough } from 'stream'; -import HttpOutgoing from '../lib/http-outgoing.js'; - -const REQ_OPTIONS = { - pathname: 'a', - query: { b: 'c' }, -}; -const RESOURCE_OPTIONS = { - uri: 'http://example.org', -}; - -/** - * Constructor - */ - -tap.test('HttpOutgoing() - object tag - should be PodletClientHttpOutgoing', t => { - const outgoing = new HttpOutgoing(RESOURCE_OPTIONS); - t.equal( - Object.prototype.toString.call(outgoing), - '[object PodletClientHttpOutgoing]', - ); - t.end(); -}); - -tap.test('HttpOutgoing() - "options.uri" not provided to constructor - should throw', t => { - t.throws(() => { - // eslint-disable-next-line no-unused-vars - const outgoing = new HttpOutgoing(); - }, 'you must pass a URI in "options.uri" to the HttpOutgoing constructor'); - t.end(); -}); - -tap.test('HttpOutgoing() - should be a PassThrough stream', t => { - const outgoing = new HttpOutgoing(RESOURCE_OPTIONS, REQ_OPTIONS); - t.ok(outgoing instanceof PassThrough); - t.end(); -}); - -tap.test('HttpOutgoing() - set "uri" - should be accessable on "this.manifestUri"', t => { - const outgoing = new HttpOutgoing(RESOURCE_OPTIONS); - t.equal(outgoing.manifestUri, RESOURCE_OPTIONS.uri); - t.end(); -}); - -tap.test('HttpOutgoing() - set "reqOptions" - should be persisted on "this.reqOptions"', t => { - const outgoing = new HttpOutgoing(RESOURCE_OPTIONS, REQ_OPTIONS); - t.equal(outgoing.reqOptions.pathname, 'a'); - t.equal(outgoing.reqOptions.query.b, 'c'); - t.end(); -}); - -tap.test('HttpOutgoing() - "this.manifest" should be {_fallback: ""}', t => { - const outgoing = new HttpOutgoing(RESOURCE_OPTIONS, REQ_OPTIONS); - t.same(outgoing.manifest, { _fallback: '' }); - t.end(); -}); - -tap.test('HttpOutgoing() - get manifestUri - should return URI to manifest', t => { - const outgoing = new HttpOutgoing(RESOURCE_OPTIONS); - t.equal(outgoing.manifestUri, RESOURCE_OPTIONS.uri); - t.end(); -}); - -tap.test('HttpOutgoing() - call .pushFallback() - should push the fallback content on the stream', t => { - t.plan(1); - const outgoing = new HttpOutgoing(RESOURCE_OPTIONS, REQ_OPTIONS); - outgoing.manifest = {}; - outgoing.fallback = '

haz fallback

'; - - const to = destinationBufferStream(result => { - t.equal(result, '

haz fallback

'); - t.end(); - }); - - outgoing.pipe(to); - - outgoing.pushFallback(); -}); - -tap.test('HttpOutgoing() - "options.throwable" is not defined - "this.throwable" should be "false"', t => { - const outgoing = new HttpOutgoing(RESOURCE_OPTIONS, REQ_OPTIONS); - t.notOk(outgoing.throwable); - t.end(); -}); - -tap.test('HttpOutgoing() - "options.throwable" is defined to be true - "this.throwable" should be "true"', t => { - const options = { - uri: 'http://example.org', - throwable: true, - }; - const outgoing = new HttpOutgoing(options, REQ_OPTIONS); - t.ok(outgoing.throwable); - t.end(); -}); diff --git a/test/integration.basic.js b/test/integration.basic.js deleted file mode 100644 index 9e39e321..00000000 --- a/test/integration.basic.js +++ /dev/null @@ -1,475 +0,0 @@ -import tap from 'tap'; -import { PodletServer, HttpServer, HttpsServer } from '@podium/test-utils'; -import { HttpIncoming } from '@podium/utils'; -import Client from '../lib/client.js'; - -// Fake headers -const headers = {}; - -tap.test('integration basic', async t => { - const serverA = new PodletServer({ name: 'aa' }); - const serverB = new PodletServer({ name: 'bb' }); - const [serviceA, serviceB] = await Promise.all([ - serverA.listen(), - serverB.listen(), - ]); - - const client = new Client({ name: 'podiumClient' }); - const a = client.register(serviceA.options); - const b = client.register(serviceB.options); - - const incomingA = new HttpIncoming({ headers }); - incomingA.context = { 'podium-locale': 'en-NZ' }; - - const actual1 = await a.fetch(incomingA); - actual1.headers.date = ''; - actual1.headers['keep-alive'] = ''; // node.js pre 14 does not have keep-alive as a default - - t.same(actual1.content, serverA.contentBody); - t.same(actual1.js, []); - t.same(actual1.css, []); - - t.same(actual1.headers, { - connection: 'keep-alive', - 'keep-alive': '', - 'content-length': '17', - 'content-type': 'text/html; charset=utf-8', - date: '', - 'podlet-version': '1.0.0', - }); - - const incomingB = new HttpIncoming({ headers }); - - const actual2 = await b.fetch(incomingB); - actual2.headers.date = ''; - actual2.headers['keep-alive'] = ''; // node.js pre 14 does not have keep-alive as a default - - t.same(actual2.content, serverB.contentBody); - t.same(actual2.js, []); - t.same(actual2.css, []); - - t.same(actual2.headers, { - connection: 'keep-alive', - 'keep-alive': '', - 'content-length': '17', - 'content-type': 'text/html; charset=utf-8', - date: '', - 'podlet-version': '1.0.0', - }); - - await Promise.all([serverA.close(), serverB.close()]); -}); - -tap.test('integration - throwable:true - remote manifest can not be resolved - should throw', async t => { - const client = new Client({ name: 'podiumClient' }); - const component = client.register({ - throwable: true, - name: 'component', - uri: 'http://does.not.exist.finn.no/manifest.json', - }); - - try { - await component.fetch(new HttpIncoming({ headers })); - } catch (error) { - t.match(error.message, /No manifest available - Cannot read content/); - } - - t.end(); -}); - -tap.test('integration - throwable:false - remote manifest can not be resolved - should resolve with empty string', async t => { - const client = new Client({ name: 'podiumClient' }); - const component = client.register({ - name: 'component', - uri: 'http://does.not.exist.finn.no/manifest.json', - }); - - const result = await component.fetch(new HttpIncoming({ headers })); - t.equal(result.content, ''); -}); - -tap.test('integration - throwable:false - remote fallback can not be resolved - should resolve with empty string', async t => { - const server = new PodletServer({ - fallback: 'http://does.not.exist.finn.no/fallback.html', - content: '/error', // set to trigger fallback senario - }); - - const service = await server.listen(); - - const client = new Client({ name: 'podiumClient' }); - const component = client.register(service.options); - - const result = await component.fetch(new HttpIncoming({ headers })); - t.equal(result.content, ''); - - await server.close(); -}); - -tap.test('integration - throwable:false - remote fallback responds with http 500 - should resolve with empty string', async t => { - const server = new PodletServer({ - fallback: 'error', - content: '/error', // set to trigger fallback senario - }); - - const service = await server.listen(); - - const client = new Client({ name: 'podiumClient' }); - const component = client.register(service.options); - - const result = await component.fetch(new HttpIncoming({ headers })); - t.equal(result.content, ''); - - await server.close(); -}); - -tap.test('integration - throwable:true - remote content can not be resolved - should throw', async t => { - const server = new PodletServer({ - fallback: '/fallback.html', - content: 'http://does.not.exist.finn.no/content.html', - }); - - const service = await server.listen(); - - const client = new Client({ name: 'podiumClient' }); - const component = client.register({ - throwable: true, - name: service.options.name, - uri: service.options.uri, - }); - - try { - await component.fetch(new HttpIncoming({ headers })); - } catch (error) { - t.match(error.message, /Error reading content/); - } - - await server.close(); - - t.end(); -}); - -tap.test('integration - throwable:false - remote content can not be resolved - should resolve with fallback', async t => { - const server = new PodletServer({ - fallback: '/fallback.html', - content: 'http://does.not.exist.finn.no/content.html', - }); - - const service = await server.listen(); - - const client = new Client({ name: 'podiumClient' }); - const component = client.register(service.options); - - const result = await component.fetch(new HttpIncoming({ headers })); - t.same(result.content, server.fallbackBody); - t.same(result.headers, {}); - t.same(result.css, []); - t.same(result.js, []); - - await server.close(); -}); - -tap.test('integration - throwable:true - remote content responds with http 500 - should throw', async t => { - const server = new PodletServer({ - fallback: '/fallback.html', - content: '/error', - }); - - const service = await server.listen(); - - const client = new Client({ name: 'podiumClient' }); - const component = client.register({ - throwable: true, - name: service.options.name, - uri: service.options.uri, - }); - - try { - await component.fetch(new HttpIncoming({ headers })); - } catch (error) { - t.match(error.message, /Could not read content/); - } - - await server.close(); - - t.end(); -}); - -tap.test('integration - throwable:false - remote content responds with http 500 - should resolve with fallback', async t => { - const server = new PodletServer({ - fallback: '/fallback.html', - content: '/error', - }); - - const service = await server.listen(); - - const client = new Client({ name: 'podiumClient' }); - const component = client.register(service.options); - - const result = await component.fetch(new HttpIncoming({ headers })); - t.same(result.content, server.fallbackBody); - t.same(result.headers, {}); - t.same(result.css, []); - t.same(result.js, []); - - await server.close(); -}); - -tap.test('integration - throwable:false - manifest / content fetching goes into recursion loop - should try to resolve 4 times before terminating and resolve with fallback', async t => { - const server = new PodletServer({ - fallback: '/fallback.html', - }); - - const service = await server.listen(); - - const client = new Client({ name: 'podiumClient' }); - const component = client.register(service.options); - await component.refresh(); - - // make http version number never match manifest version number - server.headersContent = { - 'podlet-version': Date.now(), - }; - - const result = await component.fetch(new HttpIncoming({ headers })); - t.same(result.content, server.fallbackBody); - t.same(result.headers, {}); - t.same(result.css, []); - t.same(result.js, []); - - // manifest and fallback is one more than default - // due to initial refresh() call - t.equal(server.metrics.manifest, 5); - t.equal(server.metrics.fallback, 5); - t.equal(server.metrics.content, 4); - - await server.close(); -}); - -tap.test('integration - throwable:true - manifest / content fetching goes into recursion loop - should try to resolve 4 times before terminating and then throw', async t => { - const server = new PodletServer({ - fallback: '/fallback.html', - }); - - const service = await server.listen(); - - const client = new Client({ name: 'podiumClient' }); - const component = client.register({ - throwable: true, - name: service.options.name, - uri: service.options.uri, - }); - await component.refresh(); - - // make http version number never match manifest version number - server.headersContent = { - 'podlet-version': Date.now(), - }; - - try { - await component.fetch(new HttpIncoming({ headers })); - } catch (error) { - t.match( - error.message, - /Recursion detected - failed to resolve fetching of podlet 4 times/, - ); - } - - // manifest and fallback is one more than default - // due to initial refresh() call - t.equal(server.metrics.manifest, 5); - t.equal(server.metrics.fallback, 5); - t.equal(server.metrics.content, 4); - - await server.close(); - - t.end(); -}); - -tap.test('integration basic - set headers argument - should pass on headers to request', async t => { - const server = new PodletServer({ name: 'podlet' }); - const service = await server.listen(); - server.on('req:content', (content, req) => { - t.equal(req.headers.foo, 'bar'); - t.equal(req.headers['podium-ctx'], 'foo'); - t.end(); - }); - - const client = new Client({ name: 'podiumClient' }); - const a = client.register(service.options); - - const incoming = new HttpIncoming({ headers }) - incoming.context = { 'podium-ctx': 'foo' }; - - await a.fetch( - incoming, - { - headers: { - foo: 'bar', - }, - }, - ); - - await server.close(); -}); - -tap.test('integration basic - set headers argument - header has a "user-agent" - should override "user-agent" with podium agent', async t => { - const server = new PodletServer({ name: 'podlet' }); - const service = await server.listen(); - server.on('req:content', (content, req) => { - t.ok(req.headers['user-agent'].startsWith('@podium/client')); - t.end(); - }); - - const client = new Client({ name: 'podiumClient' }); - const a = client.register(service.options); - - const incoming = new HttpIncoming({ headers }) - incoming.context = { 'podium-ctx': 'foo' }; - - await a.fetch( - incoming, - { - headers: { - 'User-Agent': 'bar', - }, - }, - ); - - await server.close(); -}); - -tap.test('integration basic - metrics stream objects created', async (t) => { - const server = new PodletServer({ name: 'podlet' }); - const client = new Client({ name: 'clientName' }); - - const metrics = []; - client.metrics.on('data', metric => metrics.push(metric)); - client.metrics.on('end', async () => { - t.equal(metrics.length, 3); - t.equal(metrics[0].name, 'podium_client_resolver_manifest_resolve'); - t.equal(metrics[0].type, 5); - t.same(metrics[0].labels[0], { - name: 'name', - value: 'clientName', - }); - t.equal(metrics[1].name, 'podium_client_resolver_fallback_resolve'); - t.equal(metrics[1].type, 5); - t.same(metrics[1].labels[0], { - name: 'name', - value: 'clientName', - }); - t.equal(metrics[2].name, 'podium_client_resolver_content_resolve'); - t.equal(metrics[2].type, 5); - t.same(metrics[2].labels[0], { - name: 'name', - value: 'clientName', - }); - - t.end() - }); - - const service = await server.listen(); - - const a = client.register(service.options); - - const incoming = new HttpIncoming({ headers }); - incoming.context = { 'podium-ctx': 'foo' }; - - await a.fetch(incoming); - client.metrics.push(null); - - await server.close() -}); - -tap.test('integration basic - "pathname" is called with different values - should append the different pathnames to the content URL', async t => { - const server = new PodletServer({ name: 'podlet', content: '/index' }); - const service = await server.listen(); - const results = []; - - server.on('req:content', (content, req) => { - results.push(req.url); - - if (server.metrics.content === 2) { - t.equal(results[0], '/index/foo'); - t.equal(results[1], '/index/bar'); - t.end(); - } - }); - - const client = new Client({ name: 'podiumClient' }); - const a = client.register(service.options); - - await a.fetch(new HttpIncoming({ headers }), { pathname: '/foo' }); - await a.fetch(new HttpIncoming({ headers }), { pathname: '/bar' }); - - await server.close(); -}); - -tap.test('integration basic - multiple hosts - mainfest is on one host but content on fallbacks on different hosts', async t => { - const contentServer = new HttpServer(); - contentServer.request = (req, res) => { - res.statusCode = 200; - res.setHeader('Content-Type', 'text/plain'); - res.end('

content

'); - }; - - const fallbackServer = new HttpServer(); - fallbackServer.request = (req, res) => { - res.statusCode = 200; - res.setHeader('Content-Type', 'text/plain'); - res.end('

fallback

'); - }; - - const [contentUrl, fallbackUrl] = await Promise.all([contentServer.listen(), fallbackServer.listen()]); - - const podletServer = new PodletServer({ name: 'aa', content: contentUrl, fallback: fallbackUrl }); - const podletUrl = await podletServer.listen(); - - const client = new Client({ name: 'podiumClient' }); - const podlet = client.register(podletUrl.options); - - const responseA = await podlet.fetch(new HttpIncoming({ headers })); - t.same(responseA.content, '

content

'); - - // Close all services to trigger fallback - await Promise.all([podletServer.close(), contentServer.close(), fallbackServer.close()]); - - const responseB = await podlet.fetch(new HttpIncoming({ headers })); - t.same(responseB.content, '

fallback

'); -}); - -tap.test('integration basic - multiple protocols - mainfest is on a http host but content on fallbacks on https hosts', { - skip: true -}, async t => { - // Undici rejects self signed SSL certs so we need to disable that for tests - const contentServer = new HttpServer(); - contentServer.request = (req, res) => { - res.statusCode = 200; - res.setHeader('Content-Type', 'text/plain'); - res.end('

content

'); - }; - - const fallbackServer = new HttpsServer(); - fallbackServer.request = (req, res) => { - res.statusCode = 200; - res.setHeader('Content-Type', 'text/plain'); - res.end('

fallback

'); - }; - - const [contentUrl, fallbackUrl] = await Promise.all([contentServer.listen(), fallbackServer.listen()]); - - const podletServer = new PodletServer({ name: 'aa', content: contentUrl, fallback: fallbackUrl }); - const podletUrl = await podletServer.listen(); - - const client = new Client({ name: 'podiumClient', rejectUnauthorized: false }); - const podlet = client.register(podletUrl.options); - - const responseA = await podlet.fetch(new HttpIncoming({ headers })); - t.same(responseA.content, '

content

'); - - // Close all services to trigger fallback - await Promise.all([podletServer.close(), contentServer.close(), fallbackServer.close()]); - - const responseB = await podlet.fetch(new HttpIncoming({ headers })); - t.same(responseB.content, '

fallback

'); -}); diff --git a/test/resolver.cache.js b/test/resolver.cache.js deleted file mode 100644 index 6604ae98..00000000 --- a/test/resolver.cache.js +++ /dev/null @@ -1,22 +0,0 @@ -/* eslint-disable import/order */ - -import tap from 'tap'; -import Cache from '../lib/resolver.cache.js'; -import TtlMemCache from 'ttl-mem-cache'; - -tap.test('resolver.cache() - object tag - should be PodletClientCacheResolver', t => { - const cache = new Cache(new TtlMemCache()); - t.equal( - Object.prototype.toString.call(cache), - '[object PodletClientCacheResolver]', - ); - t.end(); -}); - -tap.test('resolver.cache() - "registry" not provided to constructor - should throw', t => { - t.throws(() => { - // eslint-disable-next-line no-unused-vars - const cache = new Cache(); - }, 'you must pass a "registry" object to the PodletClientCacheResolver constructor'); - t.end(); -}); diff --git a/test/resolver.content.js b/test/resolver.content.js deleted file mode 100644 index dbc108a4..00000000 --- a/test/resolver.content.js +++ /dev/null @@ -1,537 +0,0 @@ -/* eslint-disable import/order */ - -import tap from 'tap'; -import HttpOutgoing from '../lib/http-outgoing.js'; -import { HttpIncoming } from '@podium/utils'; -import Content from '../lib/resolver.content.js'; -import * as utils from '@podium/utils'; -import { - destinationBufferStream, - PodletServer, - HttpServer, -} from '@podium/test-utils'; - -// Fake headers -const headers = {}; - -/** - * TODO I: - * If "outgoing.reqOptions.podiumContext" does not exist the content resolver throws a - * "Cannot read property 'resourceMountPath' of undefined" error. - * This is britle in the implementation. Harden. - * - * TODO II: - * Resolving URI's should happen in outgoing object and not in manifest resolver. - */ - -tap.test('resolver.content() - object tag - should be PodletClientContentResolver', t => { - const content = new Content(); - t.equal( - Object.prototype.toString.call(content), - '[object PodletClientContentResolver]', - ); - t.end(); -}); - -tap.test('resolver.content() - "podlet-version" header is same as manifest.version - should keep manifest on outgoing.manifest', async t => { - const server = new PodletServer(); - const service = await server.listen(); - const outgoing = new HttpOutgoing({ uri: service.options.uri }, {}, new HttpIncoming({ headers })); - - // See TODO II - const { manifest } = server; - manifest.content = utils.uriRelativeToAbsolute( - server.manifest.content, - outgoing.manifestUri, - ); - - outgoing.manifest = manifest; - outgoing.status = 'cached'; - - // See TODO I - outgoing.reqOptions.podiumContext = {}; - - const content = new Content(); - await content.resolve(outgoing); - - t.ok(outgoing.manifest); - await server.close(); - t.end(); -}); - -tap.test('resolver.content() - "podlet-version" header is empty - should keep manifest on outgoing.manifest', async t => { - const server = new PodletServer(); - const service = await server.listen(); - server.headersContent = { - 'podlet-version': '', - }; - - const outgoing = new HttpOutgoing({ uri: service.options.uri }, {}, new HttpIncoming({ headers })); - - // See TODO II - const { manifest } = server; - manifest.content = utils.uriRelativeToAbsolute( - server.manifest.content, - outgoing.manifestUri, - ); - - outgoing.manifest = manifest; - outgoing.status = 'cached'; - - // See TODO I - outgoing.reqOptions.podiumContext = {}; - - const content = new Content(); - await content.resolve(outgoing); - - t.ok(outgoing.manifest); - await server.close(); - t.end(); -}); - -tap.test('resolver.content() - "podlet-version" header is different than manifest.version - should set outgoing.status to "stale" and keep manifest', async t => { - const server = new PodletServer(); - const service = await server.listen(); - server.headersContent = { - 'podlet-version': '2.0.0', - }; - - const outgoing = new HttpOutgoing({ uri: service.options.uri }, {}, new HttpIncoming({ headers })); - - // See TODO II - const { manifest } = server; - manifest.content = utils.uriRelativeToAbsolute( - server.manifest.content, - outgoing.manifestUri, - ); - - outgoing.manifest = manifest; - outgoing.status = 'cached'; - - // See TODO I - outgoing.reqOptions.podiumContext = {}; - - const content = new Content(); - await content.resolve(outgoing); - - t.equal(outgoing.manifest.version, server.manifest.version); - t.equal(outgoing.status, 'stale'); - await server.close(); - t.end(); -}); - -tap.test('resolver.content() - throwable:true - remote can not be resolved - should throw', async t => { - const outgoing = new HttpOutgoing({ - uri: 'http://does.not.exist.finn.no/manifest.json', - throwable: true, - }, {}, new HttpIncoming({ headers })); - - // See TODO I - outgoing.reqOptions.podiumContext = {}; - - outgoing.manifest = { - content: 'http://does.not.exist.finn.no/index.html', - }; - outgoing.status = 'cached'; - - const content = new Content(); - - try { - await content.resolve(outgoing); - } catch (error) { - t.match(error.message, /Error reading content/); - t.notOk(outgoing.success); - } - t.end(); -}); - -tap.test('resolver.content() - throwable:true - remote responds with http 500 - should throw', async t => { - const server = new PodletServer(); - const service = await server.listen(); - - const outgoing = new HttpOutgoing({ - uri: service.options.uri, - throwable: true, - }, {}, new HttpIncoming({ headers })); - - // See TODO I - outgoing.reqOptions.podiumContext = {}; - - outgoing.manifest = { - content: service.error, - }; - outgoing.status = 'cached'; - - const content = new Content(); - - try { - await content.resolve(outgoing); - } catch (error) { - t.equal(error.statusCode, 500); - t.equal(error.output.statusCode, 500); // backwards compat - t.match(error.message, /Could not read content/); - t.notOk(outgoing.success); - } - - await server.close(); - t.end(); -}); - -tap.test('resolver.content() - throwable:true - remote responds with http 404 - should throw with error object reflecting status code podlet responded with', async t => { - const server = new PodletServer(); - const service = await server.listen(); - - const outgoing = new HttpOutgoing({ - uri: service.options.uri, - throwable: true, - }, {}, new HttpIncoming({ headers })); - - // See TODO I - outgoing.reqOptions.podiumContext = {}; - - outgoing.manifest = { - content: `${service.address}/404`, - }; - outgoing.status = 'cached'; - - const content = new Content(); - - try { - await content.resolve(outgoing); - } catch (error) { - t.equal(error.statusCode, 404); - t.equal(error.output.statusCode, 404); // backwards compat - t.match(error.message, /Could not read content/); - t.notOk(outgoing.success); - } - - await server.close(); - t.end(); -}); - -tap.test('resolver.content() - throwable:false - remote can not be resolved - "outgoing" should stream empty string', t => { - const outgoing = new HttpOutgoing({ - uri: 'http://does.not.exist.finn.no/manifest.json', - throwable: false, - }, {}, new HttpIncoming({ headers })); - - // See TODO I - outgoing.reqOptions.podiumContext = {}; - - outgoing.manifest = { - content: 'http://does.not.exist.finn.no/index.html', - }; - outgoing.status = 'cached'; - - const to = destinationBufferStream(result => { - t.equal(result, ''); - t.ok(outgoing.success); - t.end(); - }); - - outgoing.pipe(to); - - const content = new Content(); - content.resolve(outgoing); -}); - -tap.test('resolver.content() - throwable:false with fallback set - remote can not be resolved - "outgoing" should stream fallback', t => { - const outgoing = new HttpOutgoing({ - uri: 'http://does.not.exist.finn.no/manifest.json', - throwable: false, - }, {}, new HttpIncoming({ headers })); - - // See TODO I - outgoing.reqOptions.podiumContext = {}; - - outgoing.manifest = { - content: 'http://does.not.exist.finn.no/index.html', - }; - outgoing.status = 'cached'; - outgoing.fallback = '

haz fallback

'; - - const to = destinationBufferStream(result => { - t.equal(result, '

haz fallback

'); - t.ok(outgoing.success); - t.end(); - }); - - outgoing.pipe(to); - - const content = new Content(); - content.resolve(outgoing); -}); - -tap.test('resolver.content() - throwable:false - remote responds with http 500 - "outgoing" should stream empty string', async t => { - const server = new PodletServer(); - const service = await server.listen(); - - const outgoing = new HttpOutgoing({ - uri: service.options.uri, - throwable: false, - }, {}, new HttpIncoming({ headers })); - - // See TODO I - outgoing.reqOptions.podiumContext = {}; - - outgoing.manifest = { - content: service.error, - }; - outgoing.status = 'cached'; - - const to = destinationBufferStream(result => { - t.equal(result, ''); - t.ok(outgoing.success); - }); - - outgoing.pipe(to); - - const content = new Content(); - await content.resolve(outgoing); - - await server.close(); - t.end(); -}); - -tap.test('resolver.content() - throwable:false with fallback set - remote responds with http 500 - "outgoing" should stream fallback', async t => { - const server = new PodletServer(); - const service = await server.listen(); - - const outgoing = new HttpOutgoing({ - uri: service.options.uri, - throwable: false, - }, {}, new HttpIncoming({ headers })); - - // See TODO I - outgoing.reqOptions.podiumContext = {}; - - outgoing.manifest = { - content: service.error, - }; - outgoing.status = 'cached'; - outgoing.fallback = '

haz fallback

'; - - const to = destinationBufferStream(result => { - t.equal(result, '

haz fallback

'); - t.ok(outgoing.success); - }); - - outgoing.pipe(to); - - const content = new Content(); - await content.resolve(outgoing); - - await server.close(); - t.end(); -}); - -tap.test('resolver.content() - kill switch - throwable:true - recursions equals threshold - should throw', async t => { - const outgoing = new HttpOutgoing({ - uri: 'http://does.not.exist.finn.no/manifest.json', - throwable: true, - }, {}, new HttpIncoming({ headers })); - - // See TODO I - outgoing.reqOptions.podiumContext = {}; - - outgoing.manifest = { - content: 'http://does.not.exist.finn.no/index.html', - }; - outgoing.status = 'cached'; - outgoing.recursions = 4; - - const content = new Content(); - - try { - await content.resolve(outgoing); - } catch (error) { - t.match( - error.message, - /Recursion detected - failed to resolve fetching of podlet 4 times/, - ); - t.notOk(outgoing.success); - } - t.end(); -}); - -tap.test('resolver.content() - kill switch - throwable:false - recursions equals threshold - "outgoing.success" should be true', async t => { - const outgoing = new HttpOutgoing({ - uri: 'http://does.not.exist.finn.no/manifest.json', - throwable: false, - }, {}, new HttpIncoming({ headers })); - - // See TODO I - outgoing.reqOptions.podiumContext = {}; - - outgoing.manifest = { - content: 'http://does.not.exist.finn.no/index.html', - }; - outgoing.status = 'cached'; - outgoing.killRecursions = 4; - - const content = new Content(); - await content.resolve(outgoing); - - t.ok(outgoing.success); - t.end(); -}); - -tap.test('resolver.content() - "redirects" 302 response should include redirect object', async t => { - const server = new PodletServer(); - server.headersContent = { - location: 'http://redirects.are.us.com', - }; - server.statusCode = 302; - const service = await server.listen(); - const outgoing = new HttpOutgoing({ - uri: service.options.uri, - redirectable: true, - }, {}, new HttpIncoming({ headers })); - - // See TODO II - const { manifest } = server; - manifest.content = utils.uriRelativeToAbsolute( - server.manifest.content, - outgoing.manifestUri, - ); - - outgoing.manifest = manifest; - outgoing.status = 'cached'; - - // See TODO I - outgoing.reqOptions.podiumContext = {}; - - const content = new Content(); - - const response = await content.resolve(outgoing); - t.same(response.redirect, { - statusCode: 302, - location: 'http://redirects.are.us.com', - }); - - await server.close(); - t.end(); -}); - -tap.test('resolver.content() - "redirectable" 200 response should not respond with redirect properties', async t => { - const server = new PodletServer(); - const service = await server.listen(); - const outgoing = new HttpOutgoing({ - uri: service.options.uri, - redirectable: true, - }, {}, new HttpIncoming({ headers })); - - // See TODO II - const { manifest } = server; - manifest.content = utils.uriRelativeToAbsolute( - server.manifest.content, - outgoing.manifestUri, - ); - - outgoing.manifest = manifest; - outgoing.status = 'cached'; - - // See TODO I - outgoing.reqOptions.podiumContext = {}; - - const content = new Content(); - - const response = await content.resolve(outgoing); - t.equal(response.redirect, null); - - await server.close(); - t.end(); -}); - -tap.test('resolver.content() - "redirects" 302 response should not throw', async t => { - const server = new PodletServer(); - server.headersContent = { - location: 'http://redirects.are.us.com', - }; - server.statusCode = 302; - const service = await server.listen(); - const outgoing = new HttpOutgoing({ - uri: service.options.uri, - redirectable: true, - throwable: true, - }, {}, new HttpIncoming({ headers })); - - // See TODO II - const { manifest } = server; - manifest.content = utils.uriRelativeToAbsolute( - server.manifest.content, - outgoing.manifestUri, - ); - - outgoing.manifest = manifest; - outgoing.status = 'cached'; - - // See TODO I - outgoing.reqOptions.podiumContext = {}; - - const content = new Content(); - - const response = await content.resolve(outgoing); - t.same(response.redirect, { - statusCode: 302, - location: 'http://redirects.are.us.com', - }); - - await server.close(); - t.end(); -}); - -tap.test('resolver.content() - "redirects" 302 response - client should follow redirect by default', async t => { - const externalService = new HttpServer(); - externalService.request = (req, res) => { - res.statusCode = 200; - res.setHeader('Content-Type', 'text/html; charset=utf-8'); - res.end('proxied content response'); - }; - const address = await externalService.listen(); - - const server = new PodletServer(); - server.headersContent = { - location: address, - }; - server.statusCode = 302; - const service = await server.listen(); - const outgoing = new HttpOutgoing({ - uri: service.options.uri, - }, {}, new HttpIncoming({ headers })); - - // See TODO II - const { manifest } = server; - manifest.content = utils.uriRelativeToAbsolute( - server.manifest.content, - outgoing.manifestUri, - ); - - outgoing.manifest = manifest; - outgoing.status = 'cached'; - - // See TODO I - outgoing.reqOptions.podiumContext = {}; - - const content = new Content(); - - const response = await content.resolve(outgoing); - - t.equal(response.success, true, 'response should be successful'); - t.equal( - response.headers['content-type'], - 'text/html; charset=utf-8', - 'response content-type header should be from proxied service', - ); - t.equal( - response.headers['content-length'], - '24', - 'content-length header should be content length of proxied service response', - ); - t.equal(outgoing.redirectable, false, 'redirectable should be false'); - t.equal(response.redirect, null, 'redirect should not be set'); - - await server.close(); - await externalService.close(); - t.end(); -}); diff --git a/test/resolver.fallback.js b/test/resolver.fallback.js deleted file mode 100644 index beaed9ac..00000000 --- a/test/resolver.fallback.js +++ /dev/null @@ -1,121 +0,0 @@ -/* eslint-disable no-underscore-dangle */ -/* eslint-disable prefer-destructuring */ - -import tap from 'tap'; -import { PodletServer } from '@podium/test-utils'; -import { HttpIncoming } from '@podium/utils'; -import HttpOutgoing from '../lib/http-outgoing.js'; -import Fallback from '../lib/resolver.fallback.js'; - -// Fake headers -const headers = {}; - -tap.test('resolver.fallback() - object tag - should be PodletClientFallbackResolver', t => { - const fallback = new Fallback(); - t.equal( - Object.prototype.toString.call(fallback), - '[object PodletClientFallbackResolver]', - ); - t.end(); -}); - -tap.test('resolver.fallback() - fallback field is empty - should set value on "outgoing.fallback" to empty String', async t => { - const server = new PodletServer(); - const manifest = server.manifest; - manifest.fallback = ''; - - const outgoing = new HttpOutgoing({ uri: 'http://example.com' }, {}, new HttpIncoming({ headers })); - outgoing.manifest = manifest; - - const fallback = new Fallback(); - const result = await fallback.resolve(outgoing); - t.equal(result.fallback, ''); - t.end(); -}); - -tap.test('resolver.fallback() - fallback field contains invalid value - should set value on "outgoing.fallback" to empty String', async t => { - const server = new PodletServer(); - const manifest = server.manifest; - manifest.fallback = 'ht++ps://blæ.finn.no/fallback.html'; - - const outgoing = new HttpOutgoing({ uri: 'http://example.com' }, {}, new HttpIncoming({ headers })); - outgoing.manifest = manifest; - - const fallback = new Fallback(); - const result = await fallback.resolve(outgoing); - t.equal(result.fallback, ''); - t.end(); -}); - -tap.test('resolver.fallback() - fallback field is a URI - should fetch fallback and set content on "outgoing.fallback"', async t => { - const server = new PodletServer(); - const service = await server.listen(); - - const manifest = server.manifest; - manifest.fallback = `${service.address}/fallback.html`; - - const outgoing = new HttpOutgoing({ uri: service.options.uri }, {}, new HttpIncoming({ headers })); - outgoing.manifest = manifest; - - const fallback = new Fallback(); - const result = await fallback.resolve(outgoing); - t.same(result.fallback, server.fallbackBody); - - await server.close(); - t.end(); -}); - -tap.test('resolver.fallback() - fallback field is a URI - should fetch fallback and set content on "outgoing.manifest._fallback"', async t => { - const server = new PodletServer(); - const service = await server.listen(); - - const manifest = server.manifest; - manifest.fallback = `${service.address}/fallback.html`; - - const outgoing = new HttpOutgoing({ uri: service.options.uri }, {}, new HttpIncoming({ headers })); - outgoing.manifest = manifest; - - const fallback = new Fallback(); - const result = await fallback.resolve(outgoing); - t.same(result.manifest._fallback, server.fallbackBody); - - await server.close(); - t.end(); -}); - -tap.test('resolver.fallback() - remote can not be resolved - "outgoing.manifest" should be empty string', async t => { - const outgoing = new HttpOutgoing({ - uri: 'http://does.not.exist.finn.no/manifest.json', - throwable: false, - }, {}, new HttpIncoming({ headers })); - - outgoing.manifest = { - fallback: 'http://does.not.exist.finn.no/fallback.html', - }; - - const fallback = new Fallback(); - await fallback.resolve(outgoing); - t.equal(outgoing.fallback, ''); - t.end(); -}); - -tap.test('resolver.fallback() - remote responds with http 500 - "outgoing.manifest" should be empty string', async t => { - const server = new PodletServer(); - const service = await server.listen(); - - const outgoing = new HttpOutgoing({ - uri: service.options.uri, - throwable: false, - }, {}, new HttpIncoming({ headers })); - - outgoing.manifest = { - fallback: service.error, - }; - - const fallback = new Fallback(); - await fallback.resolve(outgoing); - t.equal(outgoing.fallback, ''); - - await server.close(); - t.end(); -}); diff --git a/test/resolver.manifest.js b/test/resolver.manifest.js deleted file mode 100644 index 5f4390b8..00000000 --- a/test/resolver.manifest.js +++ /dev/null @@ -1,361 +0,0 @@ -/* eslint-disable import/order */ - -import tap from 'tap'; -import { PodletServer } from '@podium/test-utils'; -import { HttpIncoming } from '@podium/utils'; -import HttpOutgoing from '../lib/http-outgoing.js'; -import Manifest from '../lib/resolver.manifest.js'; -import Client from '../lib/client.js'; -import lolex from '@sinonjs/fake-timers'; - -// Fake headers -const headers = {}; - - -/** - * NOTE I: - * Cache control based on headers subract the time of the request - * so we will not have an exact number to test on. Due to this, we - * check if cache time are within a range. - */ - -tap.test('resolver.manifest() - object tag - should be PodletClientManifestResolver', t => { - const manifest = new Manifest(); - t.equal( - Object.prototype.toString.call(manifest), - '[object PodletClientManifestResolver]', - ); - t.end(); -}); - -tap.test('resolver.manifest() - "outgoing.manifest" holds a manifest - should resolve with same manifest', async t => { - const manifest = new Manifest(); - const outgoing = new HttpOutgoing({ - uri: 'http://does.not.mather.com', - }, {}, new HttpIncoming({ headers })); - outgoing.manifest = { name: 'component' }; - - await manifest.resolve(outgoing); - - t.equal(outgoing.manifest.name, 'component'); - t.end(); -}); - -tap.test('resolver.manifest() - remote has no cache header - should set outgoing.maxAge to default', async t => { - const server = new PodletServer(); - const service = await server.listen(); - - const manifest = new Manifest(); - const outgoing = new HttpOutgoing({ - uri: service.options.uri, - maxAge: 40000, - }, {}, new HttpIncoming({ headers })); - - await manifest.resolve(outgoing); - - t.equal(outgoing.maxAge, 40000); - - await server.close(); - t.end(); -}); - -tap.test('resolver.manifest() - remote has "cache-control: public, max-age=10" header - should set outgoing.maxAge to header value', async t => { - const server = new PodletServer(); - const service = await server.listen(); - server.headersManifest = { - 'cache-control': 'public, max-age=10', - }; - - const manifest = new Manifest(); - const outgoing = new HttpOutgoing({ - uri: service.options.uri, - maxAge: 40000, - }, {}, new HttpIncoming({ headers })); - - await manifest.resolve(outgoing); - - // See NOTE I for details - t.ok(outgoing.maxAge < 11000 && outgoing.maxAge > 8500); - - await server.close(); - t.end(); -}); - -tap.test('resolver.manifest() - remote has "cache-control: no-cache" header - should set outgoing.maxAge to default', async t => { - const server = new PodletServer(); - const service = await server.listen(); - server.headersManifest = { - 'cache-control': 'no-cache', - }; - - const manifest = new Manifest(); - const outgoing = new HttpOutgoing({ - uri: service.options.uri, - maxAge: 40000, - }, {}, new HttpIncoming({ headers })); - - await manifest.resolve(outgoing); - - t.equal(outgoing.maxAge, 40000); - - await server.close(); - t.end(); -}); - -tap.test('resolver.manifest() - remote has "expires" header - should set outgoing.maxAge to header value', async t => { - const server = new PodletServer(); - const service = await server.listen(); - - // Set expire header time to two hours into future - server.headersManifest = { - expires: new Date(Date.now() + 7200000).toUTCString(), - }; - - const manifest = new Manifest(); - const outgoing = new HttpOutgoing({ - uri: service.options.uri, - maxAge: 40000, - }, {}, new HttpIncoming({ headers })); - - await manifest.resolve(outgoing); - - t.ok(outgoing.maxAge <= 7200000 && outgoing.maxAge > 7195000); // 2 hours - - await server.close(); - t.end(); -}); - -tap.test('resolver.manifest() - one remote has "expires" header second none - should set and timout one and use default for second', async t => { - const now = Date.now(); - const clock = lolex.install({ now }); - - const serverA = new PodletServer({ - name: 'aa', - }); - const serverB = new PodletServer({ - name: 'bb', - }); - - const serviceA = await serverA.listen(); - const serviceB = await serverB.listen(); - - // Set expires by http headers two hours into future - serverA.headersManifest = { - expires: new Date(now + 1000 * 60 * 60 * 2).toUTCString(), - }; - - // Set default expires four hours into future - const client = new Client({ - name: 'podiumClient', - maxAge: 1000 * 60 * 60 * 4, - }); - const a = client.register(serviceA.options); - const b = client.register(serviceB.options); - - await a.fetch(new HttpIncoming({ headers })); - await b.fetch(new HttpIncoming({ headers })); - - t.equal(serverA.metrics.manifest, 1); - t.equal(serverB.metrics.manifest, 1); - - // Tick clock three hours into future - clock.tick(1000 * 60 * 60 * 3); - - await a.fetch(new HttpIncoming({ headers })); - await b.fetch(new HttpIncoming({ headers })); - - // Cache for server A should now have timed out - t.equal(serverA.metrics.manifest, 2); - t.equal(serverB.metrics.manifest, 1); - - await serverA.close(); - await serverB.close(); - clock.uninstall(); - t.end(); -}); - -tap.test('resolver.manifest() - remote can not be resolved - "outgoing.manifest" should be {_fallback: ""}', async t => { - const manifest = new Manifest(); - const outgoing = new HttpOutgoing({ - uri: 'http://does.not.exist.finn.no/manifest.json', - throwable: false, - }, {}, new HttpIncoming({ headers })); - - await manifest.resolve(outgoing); - t.same(outgoing.manifest, { _fallback: '' }); - t.end(); -}); - -tap.test('resolver.manifest() - remote responds with http 500 - "outgoing.manifest" should be {_fallback: ""}', async t => { - const server = new PodletServer(); - const service = await server.listen(); - - const manifest = new Manifest(); - const outgoing = new HttpOutgoing({ - uri: service.error, - throwable: false, - }, {}, new HttpIncoming({ headers })); - - await manifest.resolve(outgoing); - t.same(outgoing.manifest, { _fallback: '' }); - - await server.close(); - t.end(); -}); - -tap.test('resolver.manifest() - manifest is not valid - "outgoing.manifest" should be {_fallback: ""}', async t => { - const server = new PodletServer(); - const service = await server.listen(); - - const manifest = new Manifest(); - const outgoing = new HttpOutgoing({ - uri: service.content, - throwable: false, - }, {}, new HttpIncoming({ headers })); - - await manifest.resolve(outgoing); - t.same(outgoing.manifest, { _fallback: '' }); - - await server.close(); - t.end(); -}); - -tap.test('resolver.manifest() - "content" in manifest is relative - "outgoing.manifest.content" should be absolute', async t => { - const server = new PodletServer(); - const service = await server.listen(); - - const manifest = new Manifest(); - const outgoing = new HttpOutgoing({ - uri: service.manifest, - }, {}, new HttpIncoming({ headers })); - - await manifest.resolve(outgoing); - t.same(outgoing.manifest.content, service.content); - - await server.close(); - t.end(); -}); - -tap.test('resolver.manifest() - "content" in manifest is absolute - "outgoing.manifest.content" should be absolute', async t => { - const server = new PodletServer({ - content: 'http://does.not.mather.com', - }); - const service = await server.listen(); - - const manifest = new Manifest(); - const outgoing = new HttpOutgoing({ - uri: service.manifest, - }, {}, new HttpIncoming({ headers })); - - await manifest.resolve(outgoing); - t.equal(outgoing.manifest.content, 'http://does.not.mather.com'); - - await server.close(); - t.end(); -}); - -tap.test('resolver.manifest() - "fallback" in manifest is relative - "outgoing.manifest.fallback" should be absolute', async t => { - const server = new PodletServer({ - fallback: '/fallback.html', - }); - - const service = await server.listen(); - - const manifest = new Manifest(); - const outgoing = new HttpOutgoing({ - uri: service.manifest, - }, {}, new HttpIncoming({ headers })); - - await manifest.resolve(outgoing); - t.equal(outgoing.manifest.fallback, `${service.address}/fallback.html`); - - await server.close(); - t.end(); -}); - -tap.test('resolver.manifest() - "fallback" in manifest is absolute - "outgoing.manifest.fallback" should be absolute', async t => { - const server = new PodletServer({ - fallback: 'http://does.not.mather.com', - }); - const service = await server.listen(); - - const manifest = new Manifest(); - const outgoing = new HttpOutgoing({ - uri: service.manifest, - }, {}, new HttpIncoming({ headers })); - - await manifest.resolve(outgoing); - t.equal(outgoing.manifest.fallback, 'http://does.not.mather.com'); - - await server.close(); - t.end(); -}); - -tap.test('resolver.manifest() - a "proxy" target in manifest is relative - should convert it to be absolute', async t => { - const server = new PodletServer({ - proxy: { - foo: '/api/foo', - }, - }); - const service = await server.listen(); - - const manifest = new Manifest(); - const outgoing = new HttpOutgoing({ - uri: service.manifest, - }, {}, new HttpIncoming({ headers })); - - await manifest.resolve(outgoing); - - t.equal(outgoing.manifest.proxy[0].target, `${service.address}/api/foo`); - t.equal(outgoing.manifest.proxy[0].name, 'foo'); - - await server.close(); - t.end(); -}); - -tap.test('resolver.manifest() - a "proxy" target in manifest is absolute - should keep it absolute', async t => { - const server = new PodletServer({ - proxy: { - bar: 'http://does.not.mather.com/api/bar', - }, - }); - const service = await server.listen(); - - const manifest = new Manifest(); - const outgoing = new HttpOutgoing({ - uri: service.manifest, - }, {}, new HttpIncoming({ headers })); - - await manifest.resolve(outgoing); - - t.equal(outgoing.manifest.proxy[0].target, 'http://does.not.mather.com/api/bar'); - t.equal(outgoing.manifest.proxy[0].name, 'bar'); - - await server.close(); - t.end(); -}); - -tap.test('resolver.manifest() - "proxy" targets in manifest is both absolute and relative - should keep absolute URIs and alter relative URIs', async t => { - const server = new PodletServer({ - proxy: { - bar: 'http://does.not.mather.com/api/bar', - foo: '/api/foo', - }, - }); - const service = await server.listen(); - - const manifest = new Manifest(); - const outgoing = new HttpOutgoing({ - uri: service.manifest, - }, {}, new HttpIncoming({ headers })); - - await manifest.resolve(outgoing); - - t.equal(outgoing.manifest.proxy[0].target, 'http://does.not.mather.com/api/bar'); - t.equal(outgoing.manifest.proxy[0].name, 'bar'); - t.equal(outgoing.manifest.proxy[1].target, `${service.address}/api/foo`); - t.equal(outgoing.manifest.proxy[1].name, 'foo'); - - await server.close(); - t.end(); -}); diff --git a/test/resource.js b/test/resource.js deleted file mode 100644 index 49fae03a..00000000 --- a/test/resource.js +++ /dev/null @@ -1,506 +0,0 @@ -/* eslint no-unused-vars: "off" */ -/* eslint-disable no-underscore-dangle */ -/* eslint-disable import/order */ - -import tap from 'tap'; -// eslint-disable-next-line import/no-unresolved -import getStream from 'get-stream'; -import stream from 'stream'; -import Cache from 'ttl-mem-cache'; - -import { HttpIncoming } from '@podium/utils'; -import Resource from '../lib/resource.js'; -import State from '../lib/state.js'; -import { PodletServer } from '@podium/test-utils'; -import Client from '../lib/client.js'; - -const URI = 'http://example.org'; - -// Fake headers -const headers = {}; - -/** - * Constructor - */ - -tap.test('Resource() - object tag - should be PodletClientResource', t => { - const resource = new Resource(new Cache(), new State(), { uri: URI }); - t.equal( - Object.prototype.toString.call(resource), - '[object PodiumClientResource]', - ); - t.end(); -}); - -tap.test('Resource() - no "registry" - should throw', t => { - t.throws(() => { - const resource = new Resource(); - }, 'you must pass a "registry" object to the PodiumClientResource constructor'); - t.end(); -}); - -tap.test('Resource() - instantiate new resource object - should have "fetch" method', t => { - const resource = new Resource(new Cache(), new State(), { uri: URI }); - t.ok(resource.fetch instanceof Function); - t.end(); -}); - -tap.test('Resource() - instantiate new resource object - should have "stream" method', t => { - const resource = new Resource(new Cache(), new State(), { uri: URI }); - t.ok(resource.stream instanceof Function); - t.end(); -}); - -// -// .fetch() -// - -tap.test('resource.fetch() - No HttpIncoming argument provided' , (t) => { - const resource = new Resource(new Cache(), new State(), {}); - t.rejects(resource.fetch(), new TypeError('you must pass an instance of "HttpIncoming" as the first argument to the .fetch() method'), 'should reject'); - t.end(); -}); - -tap.test('resource.fetch() - should return a promise', async t => { - const server = new PodletServer({ version: '1.0.0' }); - const service = await server.listen(); - - const resource = new Resource(new Cache(), new State(), service.options); - const fetch = resource.fetch(new HttpIncoming({ headers })); - t.ok(fetch instanceof Promise); - - await fetch; - - await server.close(); - t.end(); -}); - -tap.test('resource.fetch() - set context - should pass it on', async t => { - const server = new PodletServer({ version: '1.0.0' }); - const service = await server.listen(); - server.on('req:content', (count, req) => { - t.equal(req.headers['podium-locale'], 'nb-NO'); - t.equal(req.headers['podium-mount-origin'], 'http://www.example.org'); - }); - - const resource = new Resource(new Cache(), new State(), service.options); - const incoming = new HttpIncoming({ headers }); - incoming.context = { - 'podium-locale': 'nb-NO', - 'podium-mount-origin': 'http://www.example.org', - }; - - await resource.fetch(incoming); - - await server.close(); - t.end(); -}); - -tap.test('resource.fetch() - returns an object with content, headers, js and css keys', async t => { - const server = new PodletServer({ - assets: { js: 'http://fakejs.com', css: 'http://fakecss.com' }, - }); - const service = await server.listen(); - const resource = new Resource(new Cache(), new State(), service.options); - - const result = await resource.fetch(new HttpIncoming({ headers })); - result.headers.date = ''; - - t.equal(result.content, '

content component

'); - t.same(result.headers, { - connection: 'keep-alive', - 'keep-alive': 'timeout=5', - 'content-length': '24', - 'content-type': 'text/html; charset=utf-8', - date: '', - 'podlet-version': '1.0.0', - }); - t.same(result.css, [ - { - type: 'text/css', - value: 'http://fakecss.com', - }, - ]); - t.same(result.js, [ - { - type: 'default', - value: 'http://fakejs.com', - }, - ]); - - await server.close(); - t.end(); -}); - -tap.test('resource.fetch() - returns empty array for js and css when no assets are present in manifest', async t => { - const server = new PodletServer(); - const service = await server.listen(); - - const resource = new Resource(new Cache(), new State(), service.options); - const result = await resource.fetch(new HttpIncoming({ headers })); - result.headers.date = ''; - - t.equal(result.content, '

content component

'); - t.same(result.headers, { - connection: 'keep-alive', - 'keep-alive': 'timeout=5', - 'content-length': '24', - 'content-type': 'text/html; charset=utf-8', - date: '', - 'podlet-version': '1.0.0', - }); - t.same(result.css, []); - t.same(result.js, []); - - await server.close(); - t.end(); -}); - -tap.test('resource.fetch() - redirectable flag - podlet responds with 302 redirect - redirect property is populated', async t => { - const server = new PodletServer(); - server.headersContent = { - location: 'http://redirects.are.us.com', - }; - server.statusCode = 302; - const service = await server.listen(); - - const resource = new Resource(new Cache(), new State(), { - ...service.options, - redirectable: true, - }); - const result = await resource.fetch(new HttpIncoming({ headers })); - result.headers.date = ''; - - t.equal(result.content, ''); - t.same(result.headers, { - connection: 'keep-alive', - 'keep-alive': 'timeout=5', - 'content-length': '24', - 'content-type': 'text/html; charset=utf-8', - date: '', - 'podlet-version': '1.0.0', - location: 'http://redirects.are.us.com', - }); - t.same(result.redirect, { - statusCode: 302, - location: 'http://redirects.are.us.com', - }); - t.same(result.css, []); - t.same(result.js, []); - - await server.close(); - t.end(); -}); - -tap.test('resource.fetch() - assets filtering by scope for a successful fetch', async t => { - t.plan(8); - - const server = new PodletServer({ version: '1.0.0' }); - const manifest = JSON.parse(server._bodyManifest); - manifest.js = [{ value: "/foo", scope: "content" }, { value: "/bar", scope: "fallback" }, { value: "/baz", scope: "all" }, { value: "/foobarbaz" }]; - manifest.css = [{ value: "/foo", scope: "content" }, { value: "/bar", scope: "fallback" }, { value: "/baz", scope: "all" }, { value: "/foobarbaz" }]; - server._bodyManifest = JSON.stringify(manifest); - - const service = await server.listen(); - - const resource = new Resource(new Cache(), new State(), service.options); - const result = await resource.fetch(new HttpIncoming({ headers })); - - t.equal(result.js.length, 3); - t.equal(result.js[0].scope, "content"); - t.equal(result.js[1].scope, "all"); - t.equal(result.js[2].scope, undefined); - t.equal(result.css.length, 3); - t.equal(result.css[0].scope, "content"); - t.equal(result.css[1].scope, "all"); - t.equal(result.css[2].scope, undefined); - - await server.close(); - t.end(); -}); - -tap.test('resource.fetch() - assets filtering by scope for an unsuccessful fetch', async t => { - t.plan(8); - - const server = new PodletServer({ version: '1.0.0' }); - const manifest = JSON.parse(server._bodyManifest); - manifest.js = [{ value: "/foo", scope: "content" }, { value: "/bar", scope: "fallback" }, { value: "/baz", scope: "all" }, { value: "/foobarbaz" }]; - manifest.css = [{ value: "/foo", scope: "content" }, { value: "/bar", scope: "fallback" }, { value: "/baz", scope: "all" }, { value: "/foobarbaz" }]; - server._bodyManifest = JSON.stringify(manifest); - - const service = await server.listen(); - - const resource = new Resource(new Cache(), new State(), service.options); - await resource.fetch(new HttpIncoming({ headers })); - - // close server to trigger fallback - await server.close(); - - const result = await resource.fetch(new HttpIncoming({ headers })); - - t.equal(result.js.length, 3); - t.equal(result.js[0].scope, "fallback"); - t.equal(result.js[1].scope, "all"); - t.equal(result.js[2].scope, undefined); - t.equal(result.css.length, 3); - t.equal(result.css[0].scope, "fallback"); - t.equal(result.css[1].scope, "all"); - t.equal(result.css[2].scope, undefined); - - t.end(); -}); - -/** - * .stream() - */ - -tap.test('resource.stream() - No HttpIncoming argument provided' , (t) => { - const resource = new Resource(new Cache(), new State(), {}); - t.plan(1); - t.throws(() => { - const strm = resource.stream(new HttpIncoming({ headers })); // eslint-disable-line no-unused-vars - }, "you must pass a \"HttpIncoming\" object as the first argument to the .stream() method", 'Should throw'); - t.end(); -}); - -tap.test('resource.stream() - should return a stream', async t => { - const server = new PodletServer({ version: '1.0.0' }); - const service = await server.listen(); - - const resource = new Resource(new Cache(), new State(), service.options); - const strm = resource.stream(new HttpIncoming({ headers })); - t.ok(strm instanceof stream); - - await getStream(strm); - - await server.close(); - t.end(); -}); - -tap.test('resource.stream() - should emit beforeStream event with no assets', async t => { - const server = new PodletServer({ version: '1.0.0' }); - const service = await server.listen(); - - const resource = new Resource(new Cache(), new State(), service.options); - const strm = resource.stream(new HttpIncoming({ headers })); - strm.once('beforeStream', (res) => { - t.equal(res.headers['podlet-version'], '1.0.0'); - t.same(res.js, []); - t.same(res.css, []); - }); - - await getStream(strm); - - await server.close(); - t.end(); -}); - -tap.test('resource.stream() - should emit beforeStream event with filtered assets', async t => { - t.plan(9); - - const server = new PodletServer({ version: '1.0.0' }); - const manifest = JSON.parse(server._bodyManifest); - manifest.js = [{ value: "/foo", scope: "content" }, { value: "/bar", scope: "fallback" }, { value: "/baz", scope: "all" }, { value: "/foobarbaz" }]; - manifest.css = [{ value: "/foo", scope: "content" }, { value: "/bar", scope: "fallback" }, { value: "/baz", scope: "all" }, { value: "/foobarbaz" }]; - server._bodyManifest = JSON.stringify(manifest); - const service = await server.listen(); - - const resource = new Resource(new Cache(), new State(), service.options); - const strm = resource.stream(new HttpIncoming({ headers })); - strm.once('beforeStream', ({ headers: h, js, css }) => { - t.equal(h['podlet-version'], '1.0.0'); - t.equal(js.length, 3); - t.equal(js[0].scope, "content"); - t.equal(js[1].scope, "all"); - t.equal(js[2].scope, undefined); - t.equal(css.length, 3); - t.equal(css[0].scope, "content"); - t.equal(css[1].scope, "all"); - t.equal(css[2].scope, undefined); - }); - - await getStream(strm); - - await server.close(); - t.end(); -}); - -tap.test('resource.stream() - should emit beforeStream event with filtered assets', async t => { - t.plan(8); - - const server = new PodletServer({ version: '1.0.0' }); - const manifest = JSON.parse(server._bodyManifest); - manifest.js = [{ value: "/foo", scope: "content" }, { value: "/bar", scope: "fallback" }, { value: "/baz", scope: "all" }, { value: "/foobarbaz" }]; - manifest.css = [{ value: "/foo", scope: "content" }, { value: "/bar", scope: "fallback" }, { value: "/baz", scope: "all" }, { value: "/foobarbaz" }]; - server._bodyManifest = JSON.stringify(manifest); - const service = await server.listen(); - - const resource = new Resource(new Cache(), new State(), service.options); - await resource.fetch(new HttpIncoming({ headers })); - - // close server to trigger fallback - await server.close(); - - const strm = resource.stream(new HttpIncoming({ headers })); - strm.once('beforeStream', ({ js, css }) => { - t.equal(js.length, 3); - t.equal(js[0].scope, "fallback"); - t.equal(js[1].scope, "all"); - t.equal(js[2].scope, undefined); - t.equal(css.length, 3); - t.equal(css[0].scope, "fallback"); - t.equal(css[1].scope, "all"); - t.equal(css[2].scope, undefined); - }); - - await getStream(strm); - t.end(); -}); - -tap.test('resource.stream() - should emit js event when js assets defined', async t => { - t.plan(1); - - const server = new PodletServer({ assets: { js: 'http://fakejs.com' } }); - const service = await server.listen(); - - const resource = new Resource(new Cache(), new State(), service.options); - const strm = resource.stream(new HttpIncoming({ headers })); - strm.once('beforeStream', ({ js }) => { - t.same(js, [{ type: 'default', value: 'http://fakejs.com' }]); - }); - - await getStream(strm); - - await server.close(); - t.end(); -}); - -tap.test('resource.stream() - should emit css event when css assets defined', async t => { - const server = new PodletServer({ assets: { css: 'http://fakecss.com' } }); - const service = await server.listen(); - - const resource = new Resource(new Cache(), new State(), service.options); - const strm = resource.stream(new HttpIncoming({ headers })); - strm.once('beforeStream', ({ css }) => { - t.same(css, [{ type: 'text/css', value: 'http://fakecss.com' }]); - }); - - await getStream(strm); - - await server.close(); - t.end(); -}); - -tap.test('resource.stream() - should emit beforeStream event before emitting data', async t => { - const server = new PodletServer({ - assets: { js: 'http://fakejs.com', css: 'http://fakecss.com' }, - }); - const service = await server.listen(); - - const resource = new Resource(new Cache(), new State(), service.options); - const strm = resource.stream(new HttpIncoming({ headers })); - const items = []; - - strm.once('beforeStream', beforeStream => { - items.push(beforeStream); - }); - strm.on('data', data => { - items.push(data.toString()); - }); - - await getStream(strm); - - t.same(items[0].css, [{ type: 'text/css', value: 'http://fakecss.com' }]); - t.same(items[0].js, [{ type: 'default', value: 'http://fakejs.com' }]); - t.equal(items[1], '

content component

'); - - await server.close(); - t.end(); -}); - -// -// .refresh() -// - -tap.test('resource.refresh() - should return a promise', async t => { - const server = new PodletServer({ version: '1.0.0' }); - const service = await server.listen(); - - const resource = new Resource(new Cache(), new State(), service.options); - const refresh = resource.refresh(); - t.ok(refresh instanceof Promise); - - await refresh; - - await server.close(); - t.end(); -}); - -tap.test('resource.refresh() - manifest is available - should return "true"', async t => { - const server = new PodletServer({ version: '1.0.0' }); - const service = await server.listen(); - - const client = new Client({ name: 'podiumClient' }); - const component = client.register(service.options); - - const result = await component.refresh(); - - t.equal(result, true); - - await server.close(); - t.end(); -}); - -tap.test('resource.refresh() - manifest is NOT available - should return "false"', async t => { - const client = new Client({ name: 'podiumClient' }); - - const component = client.register({ - name: 'component', - uri: 'http://does.not.exist.finn.no/manifest.json', - }); - - const result = await component.refresh(); - - t.equal(result, false); - t.end(); -}); - -tap.test('resource.refresh() - manifest with fallback is available - should get manifest and fallback, but not content', async t => { - const server = new PodletServer({ version: '1.0.0' }); - const service = await server.listen(); - - const client = new Client({ name: 'podiumClient' }); - const component = client.register(service.options); - - await component.refresh(); - - t.equal(server.metrics.manifest, 1); - t.equal(server.metrics.fallback, 1); - t.equal(server.metrics.content, 0); - - await server.close(); - t.end(); -}); - -// -// .uri -// - -tap.test('Resource().uri - instantiate new resource object - expose own uri', t => { - const resource = new Resource(new Cache(), new State(), { uri: URI }); - t.equal(resource.uri, URI); - t.end(); -}); - -// -// .name -// - -tap.test('Resource().name - instantiate new resource object - expose own name', t => { - const resource = new Resource(new Cache(), new State(), { - uri: URI, - name: 'someName', - }); - t.equal(resource.name, 'someName'); - t.end(); -}); diff --git a/test/response.js b/test/response.js deleted file mode 100644 index 03e0df0b..00000000 --- a/test/response.js +++ /dev/null @@ -1,149 +0,0 @@ -import tap from 'tap'; -import Response from '../lib/response.js'; - -tap.test('Response() - object tag - should be PodiumClientResponse', t => { - const response = new Response(); - t.equal( - Object.prototype.toString.call(response), - '[object PodiumClientResponse]', - ); - t.end(); -}); - -tap.test('Response() - no arguments - should set "content" to empty Sting', t => { - const response = new Response(); - t.equal(response.content, ''); - t.end(); -}); - -tap.test('Response() - no arguments - should set "content" to empty Sting', t => { - const response = new Response(); - t.equal(response.content, ''); - t.end(); -}); - -tap.test('Response() - no arguments - should set "headers" to empty Object', t => { - const response = new Response(); - t.same(response.headers, {}); - t.end(); -}); - -tap.test('Response() - no arguments - should set "css" to empty Array', t => { - const response = new Response(); - t.same(response.css, []); - t.end(); -}); - -tap.test('Response() - no arguments - should set "js" to empty Array', t => { - const response = new Response(); - t.same(response.js, []); - t.end(); -}); - -tap.test('Response() - no arguments - should return default values when calling toJSON()', t => { - const response = new Response(); - t.same(response.toJSON(), { - content: '', - headers: {}, - css: [], - js: [], - redirect: null, - }); - t.end(); -}); - -tap.test('Response() - no arguments - should return default content value when calling toString()', t => { - const response = new Response(); - t.same(response.toString(), ''); - t.end(); -}); - -tap.test('Response() - "content" argument has a value - should set value on "content"', t => { - const response = new Response({ content: 'foo' }); - t.equal(response.content, 'foo'); - t.end(); -}); - -tap.test('Response() - "headers" argument has a value - should set value on "headers"', t => { - const response = new Response({ headers: { foo: 'bar' } }); - t.same(response.headers, { foo: 'bar' }); - t.end(); -}); - -tap.test('Response() - "css" argument has a value - should set value on "css"', t => { - const response = new Response({ css: ['foo'] }); - t.same(response.css, ['foo']); - t.end(); -}); - -tap.test('Response() - "js" argument has a value - should set value on "js"', t => { - const response = new Response({ js: ['foo'] }); - t.same(response.js, ['foo']); - t.end(); -}); - -tap.test('Response() - arguments is set - should return set values when calling toJSON()', t => { - const response = new Response({ - content: 'foo', - headers: { foo: 'bar' }, - css: ['foo'], - js: ['foo'], - redirect: { statusCode: 302, location: 'http://redirects.are.us.com' }, - }); - t.same(response.toJSON(), { - content: 'foo', - headers: { foo: 'bar' }, - css: ['foo'], - js: ['foo'], - redirect: { statusCode: 302, location: 'http://redirects.are.us.com' }, - }); - t.end(); -}); - -tap.test('Response() - arguments is set - should return set content value when calling toString()', t => { - const response = new Response({ - content: 'foo', - headers: { foo: 'bar' }, - css: ['foo'], - js: ['foo'], - }); - t.equal(response.toString(), 'foo'); - t.end(); -}); - -tap.test('Response() - use Object in String literal - should use value of set content', t => { - const response = new Response({ - content: 'foo', - headers: { foo: 'bar' }, - css: ['foo'], - js: ['foo'], - }); - t.equal(`bar ${response}`, 'bar foo'); - t.end(); -}); - -tap.test('Response() - concatinate Object with other String - should use value of set content', t => { - const response = new Response({ - content: 'foo', - headers: { foo: 'bar' }, - css: ['foo'], - js: ['foo'], - }); - t.equal(`bar ${response}`, 'bar foo'); - t.end(); -}); - -tap.test('Response() - JSON.stringify object - should return JSON string object', t => { - const response = new Response({ - content: 'foo', - headers: { foo: 'bar' }, - css: ['foo'], - js: ['foo'], - redirect: null, - }); - t.equal( - JSON.stringify(response), - '{"redirect":null,"content":"foo","headers":{"foo":"bar"},"css":["foo"],"js":["foo"]}', - ); - t.end(); -}); diff --git a/tests/client.register.test.js b/tests/client.register.test.js new file mode 100644 index 00000000..89873768 --- /dev/null +++ b/tests/client.register.test.js @@ -0,0 +1,150 @@ +import tap from 'tap'; +import Resource from '../lib/resource.js'; +import Client from '../lib/client.js'; + +/** + * .register() + */ + +tap.test( + 'client.register() - call with a valid value for "options.uri" - should return a "Resources" object', + (t) => { + const client = new Client({ name: 'podiumClient' }); + const resource = client.register({ + uri: 'http://example-a.org', + name: 'example', + }); + t.ok(resource instanceof Resource); + t.end(); + }, +); + +tap.test('client.register() - call with no options - should throw', (t) => { + const client = new Client({ name: 'podiumClient' }); + t.throws(() => { + // @ts-expect-error Testing bad input + client.register(); + }, 'The value, "undefined", for the required argument "name" on the .register() method is not defined or not valid.'); + t.end(); +}); + +tap.test( + 'client.register() - call with missing value for "options.uri" - should throw', + (t) => { + const client = new Client({ name: 'podiumClient' }); + + t.throws(() => { + // @ts-expect-error Testing bad input + client.register({ name: 'example' }); + }, 'The value, "undefined", for the required argument "uri" on the .register() method is not defined or not valid.'); + t.end(); + }, +); + +tap.test( + 'client.register() - call with a invalid value for "options.uri" - should throw', + (t) => { + const client = new Client({ name: 'podiumClient' }); + + t.throws(() => { + client.register({ uri: '/wrong', name: 'someName' }); + }, 'The value, "/wrong", for the required argument "uri" on the .register() method is not defined or not valid.'); + t.end(); + }, +); + +tap.test( + 'client.register() - call with a invalid value for "options.name" - should throw', + (t) => { + const client = new Client({ name: 'podiumClient' }); + + t.throws(() => { + client.register({ uri: 'http://example-a.org', name: 'some name' }); + }, 'The value, "some name", for the required argument "name" on the .register() method is not defined or not valid.'); + t.end(); + }, +); + +tap.test( + 'client.register() - call with missing value for "options.name" - should throw', + (t) => { + const client = new Client({ name: 'podiumClient' }); + + t.throws(() => { + // @ts-expect-error Testing bad input + client.register({ uri: 'http://example-a.org' }); + }, 'The value, "undefined", for the required argument "name" on the .register() method is not defined or not valid.'); + t.end(); + }, +); + +tap.test('client.register() - call duplicate names - should throw', (t) => { + const client = new Client({ name: 'podiumClient' }); + client.register({ uri: 'http://example-a.org', name: 'someName' }); + + t.throws(() => { + client.register({ uri: 'http://example-a.org', name: 'someName' }); + }, 'Resource with the name "someName" has already been registered.'); + t.end(); +}); + +tap.test( + 'client.register() - register resources - should set resource as property of client object', + (t) => { + const client = new Client({ name: 'podiumClient' }); + const a = client.register({ + uri: 'http://example-a.org', + name: 'exampleA', + }); + const b = client.register({ + uri: 'http://example-b.org', + name: 'exampleB', + }); + + // @ts-expect-error This is generated runtime + t.ok(client.exampleA); + // @ts-expect-error This is generated runtime + t.ok(client.exampleB); + // @ts-expect-error This is generated runtime + t.equal(a, client.exampleA); + // @ts-expect-error This is generated runtime + t.equal(b, client.exampleB); + t.end(); + }, +); + +tap.test( + 'client.register() - register resources - should be possible to iterate over resources set on client object', + (t) => { + const client = new Client({ name: 'podiumClient' }); + const a = client.register({ + uri: 'http://example-a.org', + name: 'exampleA', + }); + const b = client.register({ + uri: 'http://example-b.org', + name: 'exampleB', + }); + + // @ts-expect-error The client implements the iterator symbol + t.same([a, b], Array.from(client)); + t.end(); + }, +); + +tap.test( + 'client.register() - try to manually set register resource - should throw', + (t) => { + const client = new Client({ name: 'podiumClient' }); + client.register({ + uri: 'http://example-a.org', + name: 'exampleA', + }); + + t.throws(() => { + // @ts-expect-error This is generated runtime + client.exampleA = 'foo'; + }, 'Cannot set read-only property.'); + t.end(); + }, +); diff --git a/test/client.js b/tests/client.test.js similarity index 100% rename from test/client.js rename to tests/client.test.js diff --git a/tests/http-outgoing.test.js b/tests/http-outgoing.test.js new file mode 100644 index 00000000..edce7aec --- /dev/null +++ b/tests/http-outgoing.test.js @@ -0,0 +1,125 @@ +import tap from 'tap'; +import { destinationBufferStream } from '@podium/test-utils'; +import { PassThrough } from 'stream'; +import HttpOutgoing from '../lib/http-outgoing.js'; + +const REQ_OPTIONS = { + pathname: 'a', + query: { b: 'c' }, +}; +const RESOURCE_OPTIONS = { + name: 'test', + timeout: 1000, + uri: 'http://example.org', + maxAge: Infinity, +}; + +/** + * Constructor + */ + +tap.test( + 'HttpOutgoing() - object tag - should be PodletClientHttpOutgoing', + (t) => { + const outgoing = new HttpOutgoing(RESOURCE_OPTIONS); + t.equal( + Object.prototype.toString.call(outgoing), + '[object PodletClientHttpOutgoing]', + ); + t.end(); + }, +); + +tap.test( + 'HttpOutgoing() - "options.uri" not provided to constructor - should throw', + (t) => { + t.throws(() => { + // eslint-disable-next-line no-unused-vars + const outgoing = new HttpOutgoing(); + }, 'you must pass a URI in "options.uri" to the HttpOutgoing constructor'); + t.end(); + }, +); + +tap.test('HttpOutgoing() - should be a PassThrough stream', (t) => { + const outgoing = new HttpOutgoing(RESOURCE_OPTIONS, REQ_OPTIONS); + t.ok(outgoing instanceof PassThrough); + t.end(); +}); + +tap.test( + 'HttpOutgoing() - set "uri" - should be accessable on "this.manifestUri"', + (t) => { + const outgoing = new HttpOutgoing(RESOURCE_OPTIONS); + t.equal(outgoing.manifestUri, RESOURCE_OPTIONS.uri); + t.end(); + }, +); + +tap.test( + 'HttpOutgoing() - set "reqOptions" - should be persisted on "this.reqOptions"', + (t) => { + const outgoing = new HttpOutgoing(RESOURCE_OPTIONS, REQ_OPTIONS); + t.equal(outgoing.reqOptions.pathname, 'a'); + t.equal(outgoing.reqOptions.query.b, 'c'); + t.end(); + }, +); + +tap.test('HttpOutgoing() - "this.manifest" should be {_fallback: ""}', (t) => { + const outgoing = new HttpOutgoing(RESOURCE_OPTIONS, REQ_OPTIONS); + t.same(outgoing.manifest, { _fallback: '' }); + t.end(); +}); + +tap.test( + 'HttpOutgoing() - get manifestUri - should return URI to manifest', + (t) => { + const outgoing = new HttpOutgoing(RESOURCE_OPTIONS); + t.equal(outgoing.manifestUri, RESOURCE_OPTIONS.uri); + t.end(); + }, +); + +tap.test( + 'HttpOutgoing() - call .pushFallback() - should push the fallback content on the stream', + (t) => { + t.plan(1); + const outgoing = new HttpOutgoing(RESOURCE_OPTIONS, REQ_OPTIONS); + // @ts-expect-error Good enough for the test + outgoing.manifest = {}; + outgoing.fallback = '

haz fallback

'; + + const to = destinationBufferStream((result) => { + t.equal(result, '

haz fallback

'); + t.end(); + }); + + outgoing.pipe(to); + + outgoing.pushFallback(); + }, +); + +tap.test( + 'HttpOutgoing() - "options.throwable" is not defined - "this.throwable" should be "false"', + (t) => { + const outgoing = new HttpOutgoing(RESOURCE_OPTIONS, REQ_OPTIONS); + t.notOk(outgoing.throwable); + t.end(); + }, +); + +tap.test( + 'HttpOutgoing() - "options.throwable" is defined to be true - "this.throwable" should be "true"', + (t) => { + const options = { + ...RESOURCE_OPTIONS, + uri: 'http://example.org', + throwable: true, + }; + const outgoing = new HttpOutgoing(options, REQ_OPTIONS); + t.ok(outgoing.throwable); + t.end(); + }, +); diff --git a/tests/integration.basic.test.js b/tests/integration.basic.test.js new file mode 100644 index 00000000..d4f15a3e --- /dev/null +++ b/tests/integration.basic.test.js @@ -0,0 +1,543 @@ +import tap from 'tap'; +import { PodletServer, HttpServer, HttpsServer } from '@podium/test-utils'; +import { HttpIncoming } from '@podium/utils'; +import Client from '../lib/client.js'; + +// Fake headers +const headers = {}; + +tap.test('integration basic', async (t) => { + const serverA = new PodletServer({ name: 'aa' }); + const serverB = new PodletServer({ name: 'bb' }); + const [serviceA, serviceB] = await Promise.all([ + serverA.listen(), + serverB.listen(), + ]); + + const client = new Client({ name: 'podiumClient' }); + const a = client.register(serviceA.options); + const b = client.register(serviceB.options); + + const incomingA = new HttpIncoming({ headers }); + incomingA.context = { 'podium-locale': 'en-NZ' }; + + const actual1 = await a.fetch(incomingA); + actual1.headers.date = ''; + actual1.headers['keep-alive'] = ''; // node.js pre 14 does not have keep-alive as a default + + t.same(actual1.content, serverA.contentBody); + t.same(actual1.js, []); + t.same(actual1.css, []); + + t.same(actual1.headers, { + connection: 'keep-alive', + 'keep-alive': '', + 'content-length': '17', + 'content-type': 'text/html; charset=utf-8', + date: '', + 'podlet-version': '1.0.0', + }); + + const incomingB = new HttpIncoming({ headers }); + + const actual2 = await b.fetch(incomingB); + actual2.headers.date = ''; + actual2.headers['keep-alive'] = ''; // node.js pre 14 does not have keep-alive as a default + + t.same(actual2.content, serverB.contentBody); + t.same(actual2.js, []); + t.same(actual2.css, []); + + t.same(actual2.headers, { + connection: 'keep-alive', + 'keep-alive': '', + 'content-length': '17', + 'content-type': 'text/html; charset=utf-8', + date: '', + 'podlet-version': '1.0.0', + }); + + await Promise.all([serverA.close(), serverB.close()]); +}); + +tap.test( + 'integration - throwable:true - remote manifest can not be resolved - should throw', + async (t) => { + const client = new Client({ name: 'podiumClient' }); + const component = client.register({ + throwable: true, + name: 'component', + uri: 'http://does.not.exist.finn.no/manifest.json', + }); + + try { + await component.fetch(new HttpIncoming({ headers })); + } catch (error) { + t.match( + error.message, + /No manifest available - Cannot read content/, + ); + } + + t.end(); + }, +); + +tap.test( + 'integration - throwable:false - remote manifest can not be resolved - should resolve with empty string', + async (t) => { + const client = new Client({ name: 'podiumClient' }); + const component = client.register({ + name: 'component', + uri: 'http://does.not.exist.finn.no/manifest.json', + }); + + const result = await component.fetch(new HttpIncoming({ headers })); + t.equal(result.content, ''); + }, +); + +tap.test( + 'integration - throwable:false - remote fallback can not be resolved - should resolve with empty string', + async (t) => { + const server = new PodletServer({ + fallback: 'http://does.not.exist.finn.no/fallback.html', + content: '/error', // set to trigger fallback senario + }); + + const service = await server.listen(); + + const client = new Client({ name: 'podiumClient' }); + const component = client.register(service.options); + + const result = await component.fetch(new HttpIncoming({ headers })); + t.equal(result.content, ''); + + await server.close(); + }, +); + +tap.test( + 'integration - throwable:false - remote fallback responds with http 500 - should resolve with empty string', + async (t) => { + const server = new PodletServer({ + fallback: 'error', + content: '/error', // set to trigger fallback senario + }); + + const service = await server.listen(); + + const client = new Client({ name: 'podiumClient' }); + const component = client.register(service.options); + + const result = await component.fetch(new HttpIncoming({ headers })); + t.equal(result.content, ''); + + await server.close(); + }, +); + +tap.test( + 'integration - throwable:true - remote content can not be resolved - should throw', + async (t) => { + const server = new PodletServer({ + fallback: '/fallback.html', + content: 'http://does.not.exist.finn.no/content.html', + }); + + const service = await server.listen(); + + const client = new Client({ name: 'podiumClient' }); + const component = client.register({ + throwable: true, + name: service.options.name, + uri: service.options.uri, + }); + + try { + await component.fetch(new HttpIncoming({ headers })); + } catch (error) { + t.match(error.message, /Error reading content/); + } + + await server.close(); + + t.end(); + }, +); + +tap.test( + 'integration - throwable:false - remote content can not be resolved - should resolve with fallback', + async (t) => { + const server = new PodletServer({ + fallback: '/fallback.html', + content: 'http://does.not.exist.finn.no/content.html', + }); + + const service = await server.listen(); + + const client = new Client({ name: 'podiumClient' }); + const component = client.register(service.options); + + const result = await component.fetch(new HttpIncoming({ headers })); + t.same(result.content, server.fallbackBody); + t.same(result.headers, {}); + t.same(result.css, []); + t.same(result.js, []); + + await server.close(); + }, +); + +tap.test( + 'integration - throwable:true - remote content responds with http 500 - should throw', + async (t) => { + const server = new PodletServer({ + fallback: '/fallback.html', + content: '/error', + }); + + const service = await server.listen(); + + const client = new Client({ name: 'podiumClient' }); + const component = client.register({ + throwable: true, + name: service.options.name, + uri: service.options.uri, + }); + + try { + await component.fetch(new HttpIncoming({ headers })); + } catch (error) { + t.match(error.message, /Could not read content/); + } + + await server.close(); + + t.end(); + }, +); + +tap.test( + 'integration - throwable:false - remote content responds with http 500 - should resolve with fallback', + async (t) => { + const server = new PodletServer({ + fallback: '/fallback.html', + content: '/error', + }); + + const service = await server.listen(); + + const client = new Client({ name: 'podiumClient' }); + const component = client.register(service.options); + + const result = await component.fetch(new HttpIncoming({ headers })); + t.same(result.content, server.fallbackBody); + t.same(result.headers, {}); + t.same(result.css, []); + t.same(result.js, []); + + await server.close(); + }, +); + +tap.test( + 'integration - throwable:false - manifest / content fetching goes into recursion loop - should try to resolve 4 times before terminating and resolve with fallback', + async (t) => { + const server = new PodletServer({ + fallback: '/fallback.html', + }); + + const service = await server.listen(); + + const client = new Client({ name: 'podiumClient' }); + const component = client.register(service.options); + await component.refresh(); + + // make http version number never match manifest version number + server.headersContent = { + 'podlet-version': Date.now(), + }; + + const result = await component.fetch(new HttpIncoming({ headers })); + t.same(result.content, server.fallbackBody); + t.same(result.headers, {}); + t.same(result.css, []); + t.same(result.js, []); + + // manifest and fallback is one more than default + // due to initial refresh() call + t.equal(server.metrics.manifest, 5); + t.equal(server.metrics.fallback, 5); + t.equal(server.metrics.content, 4); + + await server.close(); + }, +); + +tap.test( + 'integration - throwable:true - manifest / content fetching goes into recursion loop - should try to resolve 4 times before terminating and then throw', + async (t) => { + const server = new PodletServer({ + fallback: '/fallback.html', + }); + + const service = await server.listen(); + + const client = new Client({ name: 'podiumClient' }); + const component = client.register({ + throwable: true, + name: service.options.name, + uri: service.options.uri, + }); + await component.refresh(); + + // make http version number never match manifest version number + server.headersContent = { + 'podlet-version': Date.now(), + }; + + try { + await component.fetch(new HttpIncoming({ headers })); + } catch (error) { + t.match( + error.message, + /Recursion detected - failed to resolve fetching of podlet 4 times/, + ); + } + + // manifest and fallback is one more than default + // due to initial refresh() call + t.equal(server.metrics.manifest, 5); + t.equal(server.metrics.fallback, 5); + t.equal(server.metrics.content, 4); + + await server.close(); + + t.end(); + }, +); + +tap.test( + 'integration basic - set headers argument - should pass on headers to request', + async (t) => { + const server = new PodletServer({ name: 'podlet' }); + const service = await server.listen(); + server.on('req:content', (content, req) => { + t.equal(req.headers.foo, 'bar'); + t.equal(req.headers['podium-ctx'], 'foo'); + t.end(); + }); + + const client = new Client({ name: 'podiumClient' }); + const a = client.register(service.options); + + const incoming = new HttpIncoming({ headers }); + incoming.context = { 'podium-ctx': 'foo' }; + + await a.fetch(incoming, { + headers: { + foo: 'bar', + }, + }); + + await server.close(); + }, +); + +tap.test( + 'integration basic - set headers argument - header has a "user-agent" - should override "user-agent" with podium agent', + async (t) => { + const server = new PodletServer({ name: 'podlet' }); + const service = await server.listen(); + server.on('req:content', (content, req) => { + t.ok(req.headers['user-agent'].startsWith('@podium/client')); + t.end(); + }); + + const client = new Client({ name: 'podiumClient' }); + const a = client.register(service.options); + + const incoming = new HttpIncoming({ headers }); + incoming.context = { 'podium-ctx': 'foo' }; + + await a.fetch(incoming, { + headers: { + 'User-Agent': 'bar', + }, + }); + + await server.close(); + }, +); + +tap.test('integration basic - metrics stream objects created', async (t) => { + const server = new PodletServer({ name: 'podlet' }); + const client = new Client({ name: 'clientName' }); + + const metrics = []; + client.metrics.on('data', (metric) => metrics.push(metric)); + client.metrics.on('end', async () => { + t.equal(metrics.length, 3); + t.equal(metrics[0].name, 'podium_client_resolver_manifest_resolve'); + t.equal(metrics[0].type, 5); + t.same(metrics[0].labels[0], { + name: 'name', + value: 'clientName', + }); + t.equal(metrics[1].name, 'podium_client_resolver_fallback_resolve'); + t.equal(metrics[1].type, 5); + t.same(metrics[1].labels[0], { + name: 'name', + value: 'clientName', + }); + t.equal(metrics[2].name, 'podium_client_resolver_content_resolve'); + t.equal(metrics[2].type, 5); + t.same(metrics[2].labels[0], { + name: 'name', + value: 'clientName', + }); + + t.end(); + }); + + const service = await server.listen(); + + const a = client.register(service.options); + + const incoming = new HttpIncoming({ headers }); + incoming.context = { 'podium-ctx': 'foo' }; + + await a.fetch(incoming); + client.metrics.push(null); + + await server.close(); +}); + +tap.test( + 'integration basic - "pathname" is called with different values - should append the different pathnames to the content URL', + async (t) => { + const server = new PodletServer({ name: 'podlet', content: '/index' }); + const service = await server.listen(); + const results = []; + + server.on('req:content', (content, req) => { + results.push(req.url); + + if (server.metrics.content === 2) { + t.equal(results[0], '/index/foo'); + t.equal(results[1], '/index/bar'); + t.end(); + } + }); + + const client = new Client({ name: 'podiumClient' }); + const a = client.register(service.options); + + await a.fetch(new HttpIncoming({ headers }), { pathname: '/foo' }); + await a.fetch(new HttpIncoming({ headers }), { pathname: '/bar' }); + + await server.close(); + }, +); + +tap.test( + 'integration basic - multiple hosts - mainfest is on one host but content on fallbacks on different hosts', + async (t) => { + const contentServer = new HttpServer(); + contentServer.request = (req, res) => { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain'); + res.end('

content

'); + }; + + const fallbackServer = new HttpServer(); + fallbackServer.request = (req, res) => { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain'); + res.end('

fallback

'); + }; + + const [contentUrl, fallbackUrl] = await Promise.all([ + contentServer.listen(), + fallbackServer.listen(), + ]); + + const podletServer = new PodletServer({ + name: 'aa', + content: contentUrl, + fallback: fallbackUrl, + }); + const podletUrl = await podletServer.listen(); + + const client = new Client({ name: 'podiumClient' }); + const podlet = client.register(podletUrl.options); + + const responseA = await podlet.fetch(new HttpIncoming({ headers })); + t.same(responseA.content, '

content

'); + + // Close all services to trigger fallback + await Promise.all([ + podletServer.close(), + contentServer.close(), + fallbackServer.close(), + ]); + + const responseB = await podlet.fetch(new HttpIncoming({ headers })); + t.same(responseB.content, '

fallback

'); + }, +); + +tap.test( + 'integration basic - multiple protocols - mainfest is on a http host but content on fallbacks on https hosts', + { + skip: true, + }, + async (t) => { + // Undici rejects self signed SSL certs so we need to disable that for tests + const contentServer = new HttpServer(); + contentServer.request = (req, res) => { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain'); + res.end('

content

'); + }; + + const fallbackServer = new HttpsServer(); + fallbackServer.request = (req, res) => { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain'); + res.end('

fallback

'); + }; + + const [contentUrl, fallbackUrl] = await Promise.all([ + contentServer.listen(), + fallbackServer.listen(), + ]); + + const podletServer = new PodletServer({ + name: 'aa', + content: contentUrl, + fallback: fallbackUrl, + }); + const podletUrl = await podletServer.listen(); + + const client = new Client({ + name: 'podiumClient', + rejectUnauthorized: false, + }); + const podlet = client.register(podletUrl.options); + + const responseA = await podlet.fetch(new HttpIncoming({ headers })); + t.same(responseA.content, '

content

'); + + // Close all services to trigger fallback + await Promise.all([ + podletServer.close(), + contentServer.close(), + fallbackServer.close(), + ]); + + const responseB = await podlet.fetch(new HttpIncoming({ headers })); + t.same(responseB.content, '

fallback

'); + }, +); diff --git a/tests/resolver.cache.test.js b/tests/resolver.cache.test.js new file mode 100644 index 00000000..f27a74b9 --- /dev/null +++ b/tests/resolver.cache.test.js @@ -0,0 +1,27 @@ +/* eslint-disable no-unused-vars */ + +import tap from 'tap'; +import TtlMemCache from 'ttl-mem-cache'; +import Cache from '../lib/resolver.cache.js'; + +tap.test( + 'resolver.cache() - object tag - should be PodletClientCacheResolver', + (t) => { + const cache = new Cache(new TtlMemCache()); + t.equal( + Object.prototype.toString.call(cache), + '[object PodletClientCacheResolver]', + ); + t.end(); + }, +); + +tap.test( + 'resolver.cache() - "registry" not provided to constructor - should throw', + (t) => { + t.throws(() => { + const cache = new Cache(); + }, 'you must pass a "registry" object to the PodletClientCacheResolver constructor'); + t.end(); + }, +); diff --git a/tests/resolver.content.test.js b/tests/resolver.content.test.js new file mode 100644 index 00000000..70babeb4 --- /dev/null +++ b/tests/resolver.content.test.js @@ -0,0 +1,660 @@ +import tap from 'tap'; +import { HttpIncoming } from '@podium/utils'; +import * as utils from '@podium/utils'; +import { + destinationBufferStream, + PodletServer, + HttpServer, +} from '@podium/test-utils'; +import HttpOutgoing from '../lib/http-outgoing.js'; +import Content from '../lib/resolver.content.js'; + +// Fake headers +const headers = {}; + +/** + * TODO II: + * Resolving URI's should happen in outgoing object and not in manifest resolver. + */ + +tap.test( + 'resolver.content() - object tag - should be PodletClientContentResolver', + (t) => { + const content = new Content(); + t.equal( + Object.prototype.toString.call(content), + '[object PodletClientContentResolver]', + ); + t.end(); + }, +); + +tap.test( + 'resolver.content() - "podlet-version" header is same as manifest.version - should keep manifest on outgoing.manifest', + async (t) => { + const server = new PodletServer(); + const service = await server.listen(); + const outgoing = new HttpOutgoing( + { + uri: service.options.uri, + name: 'test', + maxAge: Infinity, + timeout: 1000, + }, + {}, + new HttpIncoming({ headers }), + ); + + // See TODO II + const { manifest } = server; + manifest.content = utils.uriRelativeToAbsolute( + server.manifest.content, + outgoing.manifestUri, + ); + + outgoing.manifest = manifest; + outgoing.status = 'cached'; + + const content = new Content(); + await content.resolve(outgoing); + + t.ok(outgoing.manifest); + await server.close(); + t.end(); + }, +); + +tap.test( + 'resolver.content() - "podlet-version" header is empty - should keep manifest on outgoing.manifest', + async (t) => { + const server = new PodletServer(); + const service = await server.listen(); + server.headersContent = { + 'podlet-version': '', + }; + + const outgoing = new HttpOutgoing( + { + uri: service.options.uri, + name: 'test', + maxAge: Infinity, + timeout: 1000, + }, + {}, + new HttpIncoming({ headers }), + ); + + // See TODO II + const { manifest } = server; + manifest.content = utils.uriRelativeToAbsolute( + server.manifest.content, + outgoing.manifestUri, + ); + + outgoing.manifest = manifest; + outgoing.status = 'cached'; + + const content = new Content(); + await content.resolve(outgoing); + + t.ok(outgoing.manifest); + await server.close(); + t.end(); + }, +); + +tap.test( + 'resolver.content() - "podlet-version" header is different than manifest.version - should set outgoing.status to "stale" and keep manifest', + async (t) => { + const server = new PodletServer(); + const service = await server.listen(); + server.headersContent = { + 'podlet-version': '2.0.0', + }; + + const outgoing = new HttpOutgoing( + { + uri: service.options.uri, + name: 'test', + maxAge: Infinity, + timeout: 1000, + }, + {}, + new HttpIncoming({ headers }), + ); + + // See TODO II + const { manifest } = server; + manifest.content = utils.uriRelativeToAbsolute( + server.manifest.content, + outgoing.manifestUri, + ); + + outgoing.manifest = manifest; + outgoing.status = 'cached'; + + const content = new Content(); + await content.resolve(outgoing); + + t.equal(outgoing.manifest.version, server.manifest.version); + t.equal(outgoing.status, 'stale'); + await server.close(); + t.end(); + }, +); + +tap.test( + 'resolver.content() - throwable:true - remote can not be resolved - should throw', + async (t) => { + const outgoing = new HttpOutgoing( + { + uri: 'http://does.not.exist.finn.no/manifest.json', + throwable: true, + name: 'test', + maxAge: Infinity, + timeout: 1000, + }, + {}, + new HttpIncoming({ headers }), + ); + + // @ts-expect-error Good enough for the test + outgoing.manifest = { + content: 'http://does.not.exist.finn.no/index.html', + }; + outgoing.status = 'cached'; + + const content = new Content(); + + try { + await content.resolve(outgoing); + } catch (error) { + t.match(error.message, /Error reading content/); + t.notOk(outgoing.success); + } + t.end(); + }, +); + +tap.test( + 'resolver.content() - throwable:true - remote responds with http 500 - should throw', + async (t) => { + const server = new PodletServer(); + const service = await server.listen(); + + const outgoing = new HttpOutgoing( + { + uri: service.options.uri, + throwable: true, + name: 'test', + maxAge: Infinity, + timeout: 1000, + }, + {}, + new HttpIncoming({ headers }), + ); + + // @ts-expect-error Good enough for the test + outgoing.manifest = { + content: service.error, + }; + outgoing.status = 'cached'; + + const content = new Content(); + + try { + await content.resolve(outgoing); + } catch (error) { + t.equal(error.statusCode, 500); + t.equal(error.output.statusCode, 500); // backwards compat + t.match(error.message, /Could not read content/); + t.notOk(outgoing.success); + } + + await server.close(); + t.end(); + }, +); + +tap.test( + 'resolver.content() - throwable:true - remote responds with http 404 - should throw with error object reflecting status code podlet responded with', + async (t) => { + const server = new PodletServer(); + const service = await server.listen(); + + const outgoing = new HttpOutgoing( + { + uri: service.options.uri, + throwable: true, + name: 'test', + maxAge: Infinity, + timeout: 1000, + }, + {}, + new HttpIncoming({ headers }), + ); + + // @ts-expect-error Good enough for the test + outgoing.manifest = { + content: `${service.address}/404`, + }; + outgoing.status = 'cached'; + + const content = new Content(); + + try { + await content.resolve(outgoing); + } catch (error) { + t.equal(error.statusCode, 404); + t.equal(error.output.statusCode, 404); // backwards compat + t.match(error.message, /Could not read content/); + t.notOk(outgoing.success); + } + + await server.close(); + t.end(); + }, +); + +tap.test( + 'resolver.content() - throwable:false - remote can not be resolved - "outgoing" should stream empty string', + (t) => { + const outgoing = new HttpOutgoing( + { + uri: 'http://does.not.exist.finn.no/manifest.json', + throwable: false, + name: 'test', + maxAge: Infinity, + timeout: 1000, + }, + {}, + new HttpIncoming({ headers }), + ); + + // @ts-expect-error Good enough for the test + outgoing.manifest = { + content: 'http://does.not.exist.finn.no/index.html', + }; + outgoing.status = 'cached'; + + const to = destinationBufferStream((result) => { + t.equal(result, ''); + t.ok(outgoing.success); + t.end(); + }); + + outgoing.pipe(to); + + const content = new Content(); + content.resolve(outgoing); + }, +); + +tap.test( + 'resolver.content() - throwable:false with fallback set - remote can not be resolved - "outgoing" should stream fallback', + (t) => { + const outgoing = new HttpOutgoing( + { + uri: 'http://does.not.exist.finn.no/manifest.json', + throwable: false, + name: 'test', + maxAge: Infinity, + timeout: 1000, + }, + {}, + new HttpIncoming({ headers }), + ); + + // @ts-expect-error Good enough for the test + outgoing.manifest = { + content: 'http://does.not.exist.finn.no/index.html', + }; + outgoing.status = 'cached'; + outgoing.fallback = '

haz fallback

'; + + const to = destinationBufferStream((result) => { + t.equal(result, '

haz fallback

'); + t.ok(outgoing.success); + t.end(); + }); + + outgoing.pipe(to); + + const content = new Content(); + content.resolve(outgoing); + }, +); + +tap.test( + 'resolver.content() - throwable:false - remote responds with http 500 - "outgoing" should stream empty string', + async (t) => { + const server = new PodletServer(); + const service = await server.listen(); + + const outgoing = new HttpOutgoing( + { + uri: service.options.uri, + throwable: false, + name: 'test', + maxAge: Infinity, + timeout: 1000, + }, + {}, + new HttpIncoming({ headers }), + ); + + // @ts-expect-error Good enough for the test + outgoing.manifest = { + content: service.error, + }; + outgoing.status = 'cached'; + + const to = destinationBufferStream((result) => { + t.equal(result, ''); + t.ok(outgoing.success); + }); + + outgoing.pipe(to); + + const content = new Content(); + await content.resolve(outgoing); + + await server.close(); + t.end(); + }, +); + +tap.test( + 'resolver.content() - throwable:false with fallback set - remote responds with http 500 - "outgoing" should stream fallback', + async (t) => { + const server = new PodletServer(); + const service = await server.listen(); + + const outgoing = new HttpOutgoing( + { + uri: service.options.uri, + throwable: false, + name: 'test', + maxAge: Infinity, + timeout: 1000, + }, + {}, + new HttpIncoming({ headers }), + ); + + // @ts-expect-error Good enough for the test + outgoing.manifest = { + content: service.error, + }; + outgoing.status = 'cached'; + outgoing.fallback = '

haz fallback

'; + + const to = destinationBufferStream((result) => { + t.equal(result, '

haz fallback

'); + t.ok(outgoing.success); + }); + + outgoing.pipe(to); + + const content = new Content(); + await content.resolve(outgoing); + + await server.close(); + t.end(); + }, +); + +tap.test( + 'resolver.content() - kill switch - throwable:true - recursions equals threshold - should throw', + async (t) => { + const outgoing = new HttpOutgoing( + { + uri: 'http://does.not.exist.finn.no/manifest.json', + throwable: true, + name: 'test', + maxAge: Infinity, + timeout: 1000, + }, + {}, + new HttpIncoming({ headers }), + ); + + // @ts-expect-error Good enough for the test + outgoing.manifest = { + content: 'http://does.not.exist.finn.no/index.html', + }; + outgoing.status = 'cached'; + outgoing.recursions = 4; + + const content = new Content(); + + try { + await content.resolve(outgoing); + } catch (error) { + t.match( + error.message, + /Recursion detected - failed to resolve fetching of podlet 4 times/, + ); + t.notOk(outgoing.success); + } + t.end(); + }, +); + +tap.test( + 'resolver.content() - kill switch - throwable:false - recursions equals threshold - "outgoing.success" should be true', + async (t) => { + const outgoing = new HttpOutgoing( + { + uri: 'http://does.not.exist.finn.no/manifest.json', + throwable: false, + name: 'test', + maxAge: Infinity, + timeout: 1000, + }, + {}, + new HttpIncoming({ headers }), + ); + + // @ts-expect-error Good enough for the test + outgoing.manifest = { + content: 'http://does.not.exist.finn.no/index.html', + }; + outgoing.status = 'cached'; + outgoing.recursions = 4; + + const content = new Content(); + await content.resolve(outgoing); + + t.ok(outgoing.success); + t.end(); + }, +); + +tap.test( + 'resolver.content() - "redirects" 302 response should include redirect object', + async (t) => { + const server = new PodletServer(); + server.headersContent = { + location: 'http://redirects.are.us.com', + }; + server.statusCode = 302; + const service = await server.listen(); + const outgoing = new HttpOutgoing( + { + uri: service.options.uri, + redirectable: true, + name: 'test', + maxAge: Infinity, + timeout: 1000, + }, + {}, + new HttpIncoming({ headers }), + ); + + // See TODO II + const { manifest } = server; + manifest.content = utils.uriRelativeToAbsolute( + server.manifest.content, + outgoing.manifestUri, + ); + + outgoing.manifest = manifest; + outgoing.status = 'cached'; + + const content = new Content(); + + const response = await content.resolve(outgoing); + t.same(response.redirect, { + statusCode: 302, + location: 'http://redirects.are.us.com', + }); + + await server.close(); + t.end(); + }, +); + +tap.test( + 'resolver.content() - "redirectable" 200 response should not respond with redirect properties', + async (t) => { + const server = new PodletServer(); + const service = await server.listen(); + const outgoing = new HttpOutgoing( + { + uri: service.options.uri, + redirectable: true, + name: 'test', + maxAge: Infinity, + timeout: 1000, + }, + {}, + new HttpIncoming({ headers }), + ); + + // See TODO II + const { manifest } = server; + manifest.content = utils.uriRelativeToAbsolute( + server.manifest.content, + outgoing.manifestUri, + ); + + outgoing.manifest = manifest; + outgoing.status = 'cached'; + + const content = new Content(); + + const response = await content.resolve(outgoing); + t.equal(response.redirect, null); + + await server.close(); + t.end(); + }, +); + +tap.test( + 'resolver.content() - "redirects" 302 response should not throw', + async (t) => { + const server = new PodletServer(); + server.headersContent = { + location: 'http://redirects.are.us.com', + }; + server.statusCode = 302; + const service = await server.listen(); + const outgoing = new HttpOutgoing( + { + uri: service.options.uri, + redirectable: true, + throwable: true, + name: 'test', + maxAge: Infinity, + timeout: 1000, + }, + {}, + new HttpIncoming({ headers }), + ); + + // See TODO II + const { manifest } = server; + manifest.content = utils.uriRelativeToAbsolute( + server.manifest.content, + outgoing.manifestUri, + ); + + outgoing.manifest = manifest; + outgoing.status = 'cached'; + + const content = new Content(); + + const response = await content.resolve(outgoing); + t.same(response.redirect, { + statusCode: 302, + location: 'http://redirects.are.us.com', + }); + + await server.close(); + t.end(); + }, +); + +tap.test( + 'resolver.content() - "redirects" 302 response - client should follow redirect by default', + async (t) => { + const externalService = new HttpServer(); + externalService.request = (req, res) => { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end('proxied content response'); + }; + const address = await externalService.listen(); + + const server = new PodletServer(); + server.headersContent = { + location: address, + }; + server.statusCode = 302; + const service = await server.listen(); + const outgoing = new HttpOutgoing( + { + uri: service.options.uri, + name: 'test', + maxAge: Infinity, + timeout: 1000, + }, + {}, + new HttpIncoming({ headers }), + ); + + // See TODO II + const { manifest } = server; + manifest.content = utils.uriRelativeToAbsolute( + server.manifest.content, + outgoing.manifestUri, + ); + + outgoing.manifest = manifest; + outgoing.status = 'cached'; + + const content = new Content(); + + const response = await content.resolve(outgoing); + + t.equal(response.success, true, 'response should be successful'); + t.equal( + response.headers['content-type'], + 'text/html; charset=utf-8', + 'response content-type header should be from proxied service', + ); + t.equal( + response.headers['content-length'], + '24', + 'content-length header should be content length of proxied service response', + ); + t.equal(outgoing.redirectable, false, 'redirectable should be false'); + t.equal(response.redirect, null, 'redirect should not be set'); + + await server.close(); + await externalService.close(); + t.end(); + }, +); diff --git a/tests/resolver.fallback.test.js b/tests/resolver.fallback.test.js new file mode 100644 index 00000000..2006e3a5 --- /dev/null +++ b/tests/resolver.fallback.test.js @@ -0,0 +1,195 @@ +/* eslint-disable no-underscore-dangle */ +/* eslint-disable prefer-destructuring */ + +import tap from 'tap'; +import { PodletServer } from '@podium/test-utils'; +import { HttpIncoming } from '@podium/utils'; +import HttpOutgoing from '../lib/http-outgoing.js'; +import Fallback from '../lib/resolver.fallback.js'; + +// Fake headers +const headers = {}; + +tap.test( + 'resolver.fallback() - object tag - should be PodletClientFallbackResolver', + (t) => { + const fallback = new Fallback(); + t.equal( + Object.prototype.toString.call(fallback), + '[object PodletClientFallbackResolver]', + ); + t.end(); + }, +); + +tap.test( + 'resolver.fallback() - fallback field is empty - should set value on "outgoing.fallback" to empty String', + async (t) => { + const server = new PodletServer(); + const manifest = server.manifest; + manifest.fallback = ''; + + const outgoing = new HttpOutgoing( + { + uri: 'http://example.com', + name: 'test', + maxAge: Infinity, + timeout: 1000, + }, + {}, + new HttpIncoming({ headers }), + ); + outgoing.manifest = manifest; + + const fallback = new Fallback(); + const result = await fallback.resolve(outgoing); + t.equal(result.fallback, ''); + t.end(); + }, +); + +tap.test( + 'resolver.fallback() - fallback field contains invalid value - should set value on "outgoing.fallback" to empty String', + async (t) => { + const server = new PodletServer(); + const manifest = server.manifest; + manifest.fallback = 'ht++ps://blæ.finn.no/fallback.html'; + + const outgoing = new HttpOutgoing( + { + uri: 'http://example.com', + name: 'test', + maxAge: Infinity, + timeout: 1000, + }, + {}, + new HttpIncoming({ headers }), + ); + outgoing.manifest = manifest; + + const fallback = new Fallback(); + const result = await fallback.resolve(outgoing); + t.equal(result.fallback, ''); + t.end(); + }, +); + +tap.test( + 'resolver.fallback() - fallback field is a URI - should fetch fallback and set content on "outgoing.fallback"', + async (t) => { + const server = new PodletServer(); + const service = await server.listen(); + + const manifest = server.manifest; + manifest.fallback = `${service.address}/fallback.html`; + + const outgoing = new HttpOutgoing( + { + uri: service.options.uri, + name: 'test', + maxAge: Infinity, + timeout: 1000, + }, + {}, + new HttpIncoming({ headers }), + ); + outgoing.manifest = manifest; + + const fallback = new Fallback(); + const result = await fallback.resolve(outgoing); + t.same(result.fallback, server.fallbackBody); + + await server.close(); + t.end(); + }, +); + +tap.test( + 'resolver.fallback() - fallback field is a URI - should fetch fallback and set content on "outgoing.manifest._fallback"', + async (t) => { + const server = new PodletServer(); + const service = await server.listen(); + + const manifest = server.manifest; + manifest.fallback = `${service.address}/fallback.html`; + + const outgoing = new HttpOutgoing( + { + uri: service.options.uri, + name: 'test', + maxAge: Infinity, + timeout: 1000, + }, + {}, + new HttpIncoming({ headers }), + ); + outgoing.manifest = manifest; + + const fallback = new Fallback(); + const result = await fallback.resolve(outgoing); + // @ts-expect-error Internal property + t.same(result.manifest._fallback, server.fallbackBody); + + await server.close(); + t.end(); + }, +); + +tap.test( + 'resolver.fallback() - remote can not be resolved - "outgoing.manifest" should be empty string', + async (t) => { + const outgoing = new HttpOutgoing( + { + uri: 'http://does.not.exist.finn.no/manifest.json', + throwable: false, + name: 'test', + maxAge: Infinity, + timeout: 1000, + }, + {}, + new HttpIncoming({ headers }), + ); + + // @ts-expect-error Good enough for the test + outgoing.manifest = { + fallback: 'http://does.not.exist.finn.no/fallback.html', + }; + + const fallback = new Fallback(); + await fallback.resolve(outgoing); + t.equal(outgoing.fallback, ''); + t.end(); + }, +); + +tap.test( + 'resolver.fallback() - remote responds with http 500 - "outgoing.manifest" should be empty string', + async (t) => { + const server = new PodletServer(); + const service = await server.listen(); + + const outgoing = new HttpOutgoing( + { + uri: service.options.uri, + throwable: false, + name: 'test', + maxAge: Infinity, + timeout: 1000, + }, + {}, + new HttpIncoming({ headers }), + ); + + // @ts-expect-error Good enough for the test + outgoing.manifest = { + fallback: service.error, + }; + + const fallback = new Fallback(); + await fallback.resolve(outgoing); + t.equal(outgoing.fallback, ''); + + await server.close(); + t.end(); + }, +); diff --git a/tests/resolver.manifest.test.js b/tests/resolver.manifest.test.js new file mode 100644 index 00000000..7dfa5735 --- /dev/null +++ b/tests/resolver.manifest.test.js @@ -0,0 +1,525 @@ +/* eslint-disable import/order */ + +import tap from 'tap'; +import { PodletServer } from '@podium/test-utils'; +import { HttpIncoming } from '@podium/utils'; +import HttpOutgoing from '../lib/http-outgoing.js'; +import Manifest from '../lib/resolver.manifest.js'; +import Client from '../lib/client.js'; +import lolex from '@sinonjs/fake-timers'; + +// Fake headers +const headers = {}; + +/** + * NOTE I: + * Cache control based on headers subract the time of the request + * so we will not have an exact number to test on. Due to this, we + * check if cache time are within a range. + */ + +tap.test( + 'resolver.manifest() - object tag - should be PodletClientManifestResolver', + (t) => { + const manifest = new Manifest(); + t.equal( + Object.prototype.toString.call(manifest), + '[object PodletClientManifestResolver]', + ); + t.end(); + }, +); + +tap.test( + 'resolver.manifest() - "outgoing.manifest" holds a manifest - should resolve with same manifest', + async (t) => { + const manifest = new Manifest(); + const outgoing = new HttpOutgoing( + { + uri: 'http://does.not.mather.com', + name: 'test', + timeout: 1000, + maxAge: Infinity, + }, + {}, + new HttpIncoming({ headers }), + ); + // @ts-expect-error Good enough for the test + outgoing.manifest = { name: 'component' }; + + await manifest.resolve(outgoing); + + t.equal(outgoing.manifest.name, 'component'); + t.end(); + }, +); + +tap.test( + 'resolver.manifest() - remote has no cache header - should set outgoing.maxAge to default', + async (t) => { + const server = new PodletServer(); + const service = await server.listen(); + + const manifest = new Manifest(); + const outgoing = new HttpOutgoing( + { + uri: service.options.uri, + maxAge: 40000, + name: 'test', + timeout: 1000, + }, + {}, + new HttpIncoming({ headers }), + ); + + await manifest.resolve(outgoing); + + t.equal(outgoing.maxAge, 40000); + + await server.close(); + t.end(); + }, +); + +tap.test( + 'resolver.manifest() - remote has "cache-control: public, max-age=10" header - should set outgoing.maxAge to header value', + async (t) => { + const server = new PodletServer(); + const service = await server.listen(); + server.headersManifest = { + 'cache-control': 'public, max-age=10', + }; + + const manifest = new Manifest(); + const outgoing = new HttpOutgoing( + { + uri: service.options.uri, + maxAge: 40000, + name: 'test', + timeout: 1000, + }, + {}, + new HttpIncoming({ headers }), + ); + + await manifest.resolve(outgoing); + + // See NOTE I for details + t.ok(outgoing.maxAge < 11000 && outgoing.maxAge > 8500); + + await server.close(); + t.end(); + }, +); + +tap.test( + 'resolver.manifest() - remote has "cache-control: no-cache" header - should set outgoing.maxAge to default', + async (t) => { + const server = new PodletServer(); + const service = await server.listen(); + server.headersManifest = { + 'cache-control': 'no-cache', + }; + + const manifest = new Manifest(); + const outgoing = new HttpOutgoing( + { + uri: service.options.uri, + maxAge: 40000, + name: 'test', + timeout: 1000, + }, + {}, + new HttpIncoming({ headers }), + ); + + await manifest.resolve(outgoing); + + t.equal(outgoing.maxAge, 40000); + + await server.close(); + t.end(); + }, +); + +tap.test( + 'resolver.manifest() - remote has "expires" header - should set outgoing.maxAge to header value', + async (t) => { + const server = new PodletServer(); + const service = await server.listen(); + + // Set expire header time to two hours into future + server.headersManifest = { + expires: new Date(Date.now() + 7200000).toUTCString(), + }; + + const manifest = new Manifest(); + const outgoing = new HttpOutgoing( + { + uri: service.options.uri, + maxAge: 40000, + name: 'test', + timeout: 1000, + }, + {}, + new HttpIncoming({ headers }), + ); + + await manifest.resolve(outgoing); + + t.ok(outgoing.maxAge <= 7200000 && outgoing.maxAge > 7195000); // 2 hours + + await server.close(); + t.end(); + }, +); + +tap.test( + 'resolver.manifest() - one remote has "expires" header second none - should set and timout one and use default for second', + async (t) => { + const now = Date.now(); + const clock = lolex.install({ now }); + + const serverA = new PodletServer({ + name: 'aa', + }); + const serverB = new PodletServer({ + name: 'bb', + }); + + const serviceA = await serverA.listen(); + const serviceB = await serverB.listen(); + + // Set expires by http headers two hours into future + serverA.headersManifest = { + expires: new Date(now + 1000 * 60 * 60 * 2).toUTCString(), + }; + + // Set default expires four hours into future + const client = new Client({ + name: 'podiumClient', + maxAge: 1000 * 60 * 60 * 4, + }); + const a = client.register(serviceA.options); + const b = client.register(serviceB.options); + + await a.fetch(new HttpIncoming({ headers })); + await b.fetch(new HttpIncoming({ headers })); + + t.equal(serverA.metrics.manifest, 1); + t.equal(serverB.metrics.manifest, 1); + + // Tick clock three hours into future + clock.tick(1000 * 60 * 60 * 3); + + await a.fetch(new HttpIncoming({ headers })); + await b.fetch(new HttpIncoming({ headers })); + + // Cache for server A should now have timed out + t.equal(serverA.metrics.manifest, 2); + t.equal(serverB.metrics.manifest, 1); + + await serverA.close(); + await serverB.close(); + clock.uninstall(); + t.end(); + }, +); + +tap.test( + 'resolver.manifest() - remote can not be resolved - "outgoing.manifest" should be {_fallback: ""}', + async (t) => { + const manifest = new Manifest(); + const outgoing = new HttpOutgoing( + { + uri: 'http://does.not.exist.finn.no/manifest.json', + throwable: false, + name: 'test', + timeout: 1000, + maxAge: Infinity, + }, + {}, + new HttpIncoming({ headers }), + ); + + await manifest.resolve(outgoing); + t.same(outgoing.manifest, { _fallback: '' }); + t.end(); + }, +); + +tap.test( + 'resolver.manifest() - remote responds with http 500 - "outgoing.manifest" should be {_fallback: ""}', + async (t) => { + const server = new PodletServer(); + const service = await server.listen(); + + const manifest = new Manifest(); + const outgoing = new HttpOutgoing( + { + uri: service.error, + throwable: false, + name: 'test', + timeout: 1000, + maxAge: Infinity, + }, + {}, + new HttpIncoming({ headers }), + ); + + await manifest.resolve(outgoing); + t.same(outgoing.manifest, { _fallback: '' }); + + await server.close(); + t.end(); + }, +); + +tap.test( + 'resolver.manifest() - manifest is not valid - "outgoing.manifest" should be {_fallback: ""}', + async (t) => { + const server = new PodletServer(); + const service = await server.listen(); + + const manifest = new Manifest(); + const outgoing = new HttpOutgoing( + { + uri: service.content, + throwable: false, + name: 'test', + timeout: 1000, + maxAge: Infinity, + }, + {}, + new HttpIncoming({ headers }), + ); + + await manifest.resolve(outgoing); + t.same(outgoing.manifest, { _fallback: '' }); + + await server.close(); + t.end(); + }, +); + +tap.test( + 'resolver.manifest() - "content" in manifest is relative - "outgoing.manifest.content" should be absolute', + async (t) => { + const server = new PodletServer(); + const service = await server.listen(); + + const manifest = new Manifest(); + const outgoing = new HttpOutgoing( + { + uri: service.manifest, + name: 'test', + timeout: 1000, + maxAge: Infinity, + }, + {}, + new HttpIncoming({ headers }), + ); + + await manifest.resolve(outgoing); + t.same(outgoing.manifest.content, service.content); + + await server.close(); + t.end(); + }, +); + +tap.test( + 'resolver.manifest() - "content" in manifest is absolute - "outgoing.manifest.content" should be absolute', + async (t) => { + const server = new PodletServer({ + content: 'http://does.not.mather.com', + }); + const service = await server.listen(); + + const manifest = new Manifest(); + const outgoing = new HttpOutgoing( + { + uri: service.manifest, + name: 'test', + timeout: 1000, + maxAge: Infinity, + }, + {}, + new HttpIncoming({ headers }), + ); + + await manifest.resolve(outgoing); + t.equal(outgoing.manifest.content, 'http://does.not.mather.com'); + + await server.close(); + t.end(); + }, +); + +tap.test( + 'resolver.manifest() - "fallback" in manifest is relative - "outgoing.manifest.fallback" should be absolute', + async (t) => { + const server = new PodletServer({ + fallback: '/fallback.html', + }); + + const service = await server.listen(); + + const manifest = new Manifest(); + const outgoing = new HttpOutgoing( + { + uri: service.manifest, + name: 'test', + timeout: 1000, + maxAge: Infinity, + }, + {}, + new HttpIncoming({ headers }), + ); + + await manifest.resolve(outgoing); + t.equal(outgoing.manifest.fallback, `${service.address}/fallback.html`); + + await server.close(); + t.end(); + }, +); + +tap.test( + 'resolver.manifest() - "fallback" in manifest is absolute - "outgoing.manifest.fallback" should be absolute', + async (t) => { + const server = new PodletServer({ + fallback: 'http://does.not.mather.com', + }); + const service = await server.listen(); + + const manifest = new Manifest(); + const outgoing = new HttpOutgoing( + { + uri: service.manifest, + name: 'test', + timeout: 1000, + maxAge: Infinity, + }, + {}, + new HttpIncoming({ headers }), + ); + + await manifest.resolve(outgoing); + t.equal(outgoing.manifest.fallback, 'http://does.not.mather.com'); + + await server.close(); + t.end(); + }, +); + +tap.test( + 'resolver.manifest() - a "proxy" target in manifest is relative - should convert it to be absolute', + async (t) => { + const server = new PodletServer({ + proxy: { + foo: '/api/foo', + }, + }); + const service = await server.listen(); + + const manifest = new Manifest(); + const outgoing = new HttpOutgoing( + { + uri: service.manifest, + name: 'test', + timeout: 1000, + maxAge: Infinity, + }, + {}, + new HttpIncoming({ headers }), + ); + + await manifest.resolve(outgoing); + + t.equal( + outgoing.manifest.proxy[0].target, + `${service.address}/api/foo`, + ); + t.equal(outgoing.manifest.proxy[0].name, 'foo'); + + await server.close(); + t.end(); + }, +); + +tap.test( + 'resolver.manifest() - a "proxy" target in manifest is absolute - should keep it absolute', + async (t) => { + const server = new PodletServer({ + proxy: { + bar: 'http://does.not.mather.com/api/bar', + }, + }); + const service = await server.listen(); + + const manifest = new Manifest(); + const outgoing = new HttpOutgoing( + { + uri: service.manifest, + name: 'test', + timeout: 1000, + maxAge: Infinity, + }, + {}, + new HttpIncoming({ headers }), + ); + + await manifest.resolve(outgoing); + + t.equal( + outgoing.manifest.proxy[0].target, + 'http://does.not.mather.com/api/bar', + ); + t.equal(outgoing.manifest.proxy[0].name, 'bar'); + + await server.close(); + t.end(); + }, +); + +tap.test( + 'resolver.manifest() - "proxy" targets in manifest is both absolute and relative - should keep absolute URIs and alter relative URIs', + async (t) => { + const server = new PodletServer({ + proxy: { + bar: 'http://does.not.mather.com/api/bar', + foo: '/api/foo', + }, + }); + const service = await server.listen(); + + const manifest = new Manifest(); + const outgoing = new HttpOutgoing( + { + uri: service.manifest, + name: 'test', + timeout: 1000, + maxAge: Infinity, + }, + {}, + new HttpIncoming({ headers }), + ); + + await manifest.resolve(outgoing); + + t.equal( + outgoing.manifest.proxy[0].target, + 'http://does.not.mather.com/api/bar', + ); + t.equal(outgoing.manifest.proxy[0].name, 'bar'); + t.equal( + outgoing.manifest.proxy[1].target, + `${service.address}/api/foo`, + ); + t.equal(outgoing.manifest.proxy[1].name, 'foo'); + + await server.close(); + t.end(); + }, +); diff --git a/test/resolver.js b/tests/resolver.test.js similarity index 51% rename from test/resolver.js rename to tests/resolver.test.js index 944da1af..5817e3f5 100644 --- a/test/resolver.js +++ b/tests/resolver.test.js @@ -1,8 +1,9 @@ +/* eslint-disable no-unused-vars */ import tap from 'tap'; import TtlMemCache from 'ttl-mem-cache'; import Resolver from '../lib/resolver.js'; -tap.test('resolver() - object tag - should be PodletClientResolver', t => { +tap.test('resolver() - object tag - should be PodletClientResolver', (t) => { const resolver = new Resolver(new TtlMemCache()); t.equal( Object.prototype.toString.call(resolver), @@ -11,10 +12,12 @@ tap.test('resolver() - object tag - should be PodletClientResolver', t => { t.end(); }); -tap.test('resolver() - "registry" not provided to constructor - should throw', t => { - t.throws(() => { - // eslint-disable-next-line no-unused-vars - const resolver = new Resolver(); - }, 'you must pass a "registry" object to the PodletClientResolver constructor'); - t.end(); -}); +tap.test( + 'resolver() - "registry" not provided to constructor - should throw', + (t) => { + t.throws(() => { + const resolver = new Resolver(); + }, 'you must pass a "registry" object to the PodletClientResolver constructor'); + t.end(); + }, +); diff --git a/tests/resource.test.js b/tests/resource.test.js new file mode 100644 index 00000000..4a4768f2 --- /dev/null +++ b/tests/resource.test.js @@ -0,0 +1,694 @@ +/* eslint no-unused-vars: "off" */ +/* eslint-disable no-underscore-dangle */ +/* eslint-disable import/order */ + +import tap from 'tap'; +// eslint-disable-next-line import/no-unresolved +import getStream from 'get-stream'; +import stream from 'stream'; +import Cache from 'ttl-mem-cache'; + +import { HttpIncoming } from '@podium/utils'; +import Resource from '../lib/resource.js'; +import State from '../lib/state.js'; +import { PodletServer } from '@podium/test-utils'; +import Client from '../lib/client.js'; + +const URI = 'http://example.org'; + +// Fake headers +const headers = {}; + +/** + * Constructor + */ + +tap.test('Resource() - object tag - should be PodletClientResource', (t) => { + const resource = new Resource(new Cache(), new State(), { + uri: URI, + name: 'someName', + clientName: 'someName', + timeout: 1000, + maxAge: Infinity, + }); + t.equal( + Object.prototype.toString.call(resource), + '[object PodiumClientResource]', + ); + t.end(); +}); + +tap.test('Resource() - no "registry" - should throw', (t) => { + t.throws(() => { + // @ts-expect-error Testing bad input + const resource = new Resource(); + }, 'you must pass a "registry" object to the PodiumClientResource constructor'); + t.end(); +}); + +tap.test( + 'Resource() - instantiate new resource object - should have "fetch" method', + (t) => { + const resource = new Resource(new Cache(), new State(), { + uri: URI, + name: 'someName', + clientName: 'someName', + timeout: 1000, + maxAge: Infinity, + }); + t.ok(resource.fetch instanceof Function); + t.end(); + }, +); + +tap.test( + 'Resource() - instantiate new resource object - should have "stream" method', + (t) => { + const resource = new Resource(new Cache(), new State(), { + uri: URI, + name: 'someName', + clientName: 'someName', + timeout: 1000, + maxAge: Infinity, + }); + t.ok(resource.stream instanceof Function); + t.end(); + }, +); + +// +// .fetch() +// + +tap.test('resource.fetch() - No HttpIncoming argument provided', (t) => { + const resource = new Resource(new Cache(), new State(), { + uri: URI, + name: 'someName', + clientName: 'someName', + timeout: 1000, + maxAge: Infinity, + }); + t.rejects( + // @ts-expect-error Testing bad input + resource.fetch(), + new TypeError( + 'you must pass an instance of "HttpIncoming" as the first argument to the .fetch() method', + ), + 'should reject', + ); + t.end(); +}); + +tap.test('resource.fetch() - should return a promise', async (t) => { + const server = new PodletServer({ version: '1.0.0' }); + const service = await server.listen(); + + const resource = new Resource(new Cache(), new State(), service.options); + const fetch = resource.fetch(new HttpIncoming({ headers })); + t.ok(fetch instanceof Promise); + + await fetch; + + await server.close(); + t.end(); +}); + +tap.test('resource.fetch() - set context - should pass it on', async (t) => { + const server = new PodletServer({ version: '1.0.0' }); + const service = await server.listen(); + server.on('req:content', (count, req) => { + t.equal(req.headers['podium-locale'], 'nb-NO'); + t.equal(req.headers['podium-mount-origin'], 'http://www.example.org'); + }); + + const resource = new Resource(new Cache(), new State(), service.options); + const incoming = new HttpIncoming({ headers }); + incoming.context = { + 'podium-locale': 'nb-NO', + 'podium-mount-origin': 'http://www.example.org', + }; + + await resource.fetch(incoming); + + await server.close(); + t.end(); +}); + +tap.test( + 'resource.fetch() - returns an object with content, headers, js and css keys', + async (t) => { + const server = new PodletServer({ + assets: { js: 'http://fakejs.com', css: 'http://fakecss.com' }, + }); + const service = await server.listen(); + const resource = new Resource( + new Cache(), + new State(), + service.options, + ); + + const result = await resource.fetch(new HttpIncoming({ headers })); + result.headers.date = ''; + + t.equal(result.content, '

content component

'); + t.same(result.headers, { + connection: 'keep-alive', + 'keep-alive': 'timeout=5', + 'content-length': '24', + 'content-type': 'text/html; charset=utf-8', + date: '', + 'podlet-version': '1.0.0', + }); + t.same(result.css, [ + { + type: 'text/css', + value: 'http://fakecss.com', + }, + ]); + t.same(result.js, [ + { + type: 'default', + value: 'http://fakejs.com', + }, + ]); + + await server.close(); + t.end(); + }, +); + +tap.test( + 'resource.fetch() - returns empty array for js and css when no assets are present in manifest', + async (t) => { + const server = new PodletServer(); + const service = await server.listen(); + + const resource = new Resource( + new Cache(), + new State(), + service.options, + ); + const result = await resource.fetch(new HttpIncoming({ headers })); + result.headers.date = ''; + + t.equal(result.content, '

content component

'); + t.same(result.headers, { + connection: 'keep-alive', + 'keep-alive': 'timeout=5', + 'content-length': '24', + 'content-type': 'text/html; charset=utf-8', + date: '', + 'podlet-version': '1.0.0', + }); + t.same(result.css, []); + t.same(result.js, []); + + await server.close(); + t.end(); + }, +); + +tap.test( + 'resource.fetch() - redirectable flag - podlet responds with 302 redirect - redirect property is populated', + async (t) => { + const server = new PodletServer(); + server.headersContent = { + location: 'http://redirects.are.us.com', + }; + server.statusCode = 302; + const service = await server.listen(); + + const resource = new Resource(new Cache(), new State(), { + ...service.options, + redirectable: true, + }); + const result = await resource.fetch(new HttpIncoming({ headers })); + result.headers.date = ''; + + t.equal(result.content, ''); + t.same(result.headers, { + connection: 'keep-alive', + 'keep-alive': 'timeout=5', + 'content-length': '24', + 'content-type': 'text/html; charset=utf-8', + date: '', + 'podlet-version': '1.0.0', + location: 'http://redirects.are.us.com', + }); + t.same(result.redirect, { + statusCode: 302, + location: 'http://redirects.are.us.com', + }); + t.same(result.css, []); + t.same(result.js, []); + + await server.close(); + t.end(); + }, +); + +tap.test( + 'resource.fetch() - assets filtering by scope for a successful fetch', + async (t) => { + t.plan(8); + + const server = new PodletServer({ version: '1.0.0' }); + const manifest = JSON.parse(server._bodyManifest); + manifest.js = [ + { value: '/foo', scope: 'content' }, + { value: '/bar', scope: 'fallback' }, + { value: '/baz', scope: 'all' }, + { value: '/foobarbaz' }, + ]; + manifest.css = [ + { value: '/foo', scope: 'content' }, + { value: '/bar', scope: 'fallback' }, + { value: '/baz', scope: 'all' }, + { value: '/foobarbaz' }, + ]; + server._bodyManifest = JSON.stringify(manifest); + + const service = await server.listen(); + + const resource = new Resource( + new Cache(), + new State(), + service.options, + ); + const result = await resource.fetch(new HttpIncoming({ headers })); + + t.equal(result.js.length, 3); + t.equal(result.js[0].scope, 'content'); + t.equal(result.js[1].scope, 'all'); + t.equal(result.js[2].scope, undefined); + t.equal(result.css.length, 3); + t.equal(result.css[0].scope, 'content'); + t.equal(result.css[1].scope, 'all'); + t.equal(result.css[2].scope, undefined); + + await server.close(); + t.end(); + }, +); + +tap.test( + 'resource.fetch() - assets filtering by scope for an unsuccessful fetch', + async (t) => { + t.plan(8); + + const server = new PodletServer({ version: '1.0.0' }); + const manifest = JSON.parse(server._bodyManifest); + manifest.js = [ + { value: '/foo', scope: 'content' }, + { value: '/bar', scope: 'fallback' }, + { value: '/baz', scope: 'all' }, + { value: '/foobarbaz' }, + ]; + manifest.css = [ + { value: '/foo', scope: 'content' }, + { value: '/bar', scope: 'fallback' }, + { value: '/baz', scope: 'all' }, + { value: '/foobarbaz' }, + ]; + server._bodyManifest = JSON.stringify(manifest); + + const service = await server.listen(); + + const resource = new Resource( + new Cache(), + new State(), + service.options, + ); + await resource.fetch(new HttpIncoming({ headers })); + + // close server to trigger fallback + await server.close(); + + const result = await resource.fetch(new HttpIncoming({ headers })); + + t.equal(result.js.length, 3); + t.equal(result.js[0].scope, 'fallback'); + t.equal(result.js[1].scope, 'all'); + t.equal(result.js[2].scope, undefined); + t.equal(result.css.length, 3); + t.equal(result.css[0].scope, 'fallback'); + t.equal(result.css[1].scope, 'all'); + t.equal(result.css[2].scope, undefined); + + t.end(); + }, +); + +/** + * .stream() + */ + +tap.test('resource.stream() - No HttpIncoming argument provided', (t) => { + const resource = new Resource(new Cache(), new State(), { + uri: URI, + name: 'someName', + clientName: 'someName', + timeout: 1000, + maxAge: Infinity, + }); + t.plan(1); + t.throws(() => { + // @ts-expect-error Testing bad input + const strm = resource.stream(); + }, 'you must pass an instance of "HttpIncoming" as the first argument to the .stream() method'); + t.end(); +}); + +tap.test('resource.stream() - should return a stream', async (t) => { + const server = new PodletServer({ version: '1.0.0' }); + const service = await server.listen(); + + const resource = new Resource(new Cache(), new State(), service.options); + const strm = resource.stream(new HttpIncoming({ headers })); + t.ok(strm instanceof stream); + + await getStream(strm); + + await server.close(); + t.end(); +}); + +tap.test( + 'resource.stream() - should emit beforeStream event with no assets', + async (t) => { + const server = new PodletServer({ version: '1.0.0' }); + const service = await server.listen(); + + const resource = new Resource( + new Cache(), + new State(), + service.options, + ); + const strm = resource.stream(new HttpIncoming({ headers })); + strm.once('beforeStream', (res) => { + t.equal(res.headers['podlet-version'], '1.0.0'); + t.same(res.js, []); + t.same(res.css, []); + }); + + await getStream(strm); + + await server.close(); + t.end(); + }, +); + +tap.test( + 'resource.stream() - should emit beforeStream event with filtered assets', + async (t) => { + t.plan(9); + + const server = new PodletServer({ version: '1.0.0' }); + const manifest = JSON.parse(server._bodyManifest); + manifest.js = [ + { value: '/foo', scope: 'content' }, + { value: '/bar', scope: 'fallback' }, + { value: '/baz', scope: 'all' }, + { value: '/foobarbaz' }, + ]; + manifest.css = [ + { value: '/foo', scope: 'content' }, + { value: '/bar', scope: 'fallback' }, + { value: '/baz', scope: 'all' }, + { value: '/foobarbaz' }, + ]; + server._bodyManifest = JSON.stringify(manifest); + const service = await server.listen(); + + const resource = new Resource( + new Cache(), + new State(), + service.options, + ); + const strm = resource.stream(new HttpIncoming({ headers })); + strm.once('beforeStream', ({ headers: h, js, css }) => { + t.equal(h['podlet-version'], '1.0.0'); + t.equal(js.length, 3); + t.equal(js[0].scope, 'content'); + t.equal(js[1].scope, 'all'); + t.equal(js[2].scope, undefined); + t.equal(css.length, 3); + t.equal(css[0].scope, 'content'); + t.equal(css[1].scope, 'all'); + t.equal(css[2].scope, undefined); + }); + + await getStream(strm); + + await server.close(); + t.end(); + }, +); + +tap.test( + 'resource.stream() - should emit beforeStream event with filtered assets', + async (t) => { + t.plan(8); + + const server = new PodletServer({ version: '1.0.0' }); + const manifest = JSON.parse(server._bodyManifest); + manifest.js = [ + { value: '/foo', scope: 'content' }, + { value: '/bar', scope: 'fallback' }, + { value: '/baz', scope: 'all' }, + { value: '/foobarbaz' }, + ]; + manifest.css = [ + { value: '/foo', scope: 'content' }, + { value: '/bar', scope: 'fallback' }, + { value: '/baz', scope: 'all' }, + { value: '/foobarbaz' }, + ]; + server._bodyManifest = JSON.stringify(manifest); + const service = await server.listen(); + + const resource = new Resource( + new Cache(), + new State(), + service.options, + ); + await resource.fetch(new HttpIncoming({ headers })); + + // close server to trigger fallback + await server.close(); + + const strm = resource.stream(new HttpIncoming({ headers })); + strm.once('beforeStream', ({ js, css }) => { + t.equal(js.length, 3); + t.equal(js[0].scope, 'fallback'); + t.equal(js[1].scope, 'all'); + t.equal(js[2].scope, undefined); + t.equal(css.length, 3); + t.equal(css[0].scope, 'fallback'); + t.equal(css[1].scope, 'all'); + t.equal(css[2].scope, undefined); + }); + + await getStream(strm); + t.end(); + }, +); + +tap.test( + 'resource.stream() - should emit js event when js assets defined', + async (t) => { + t.plan(1); + + const server = new PodletServer({ + assets: { js: 'http://fakejs.com' }, + }); + const service = await server.listen(); + + const resource = new Resource( + new Cache(), + new State(), + service.options, + ); + const strm = resource.stream(new HttpIncoming({ headers })); + strm.once('beforeStream', ({ js }) => { + t.same(js, [{ type: 'default', value: 'http://fakejs.com' }]); + }); + + await getStream(strm); + + await server.close(); + t.end(); + }, +); + +tap.test( + 'resource.stream() - should emit css event when css assets defined', + async (t) => { + const server = new PodletServer({ + assets: { css: 'http://fakecss.com' }, + }); + const service = await server.listen(); + + const resource = new Resource( + new Cache(), + new State(), + service.options, + ); + const strm = resource.stream(new HttpIncoming({ headers })); + strm.once('beforeStream', ({ css }) => { + t.same(css, [{ type: 'text/css', value: 'http://fakecss.com' }]); + }); + + await getStream(strm); + + await server.close(); + t.end(); + }, +); + +tap.test( + 'resource.stream() - should emit beforeStream event before emitting data', + async (t) => { + const server = new PodletServer({ + assets: { js: 'http://fakejs.com', css: 'http://fakecss.com' }, + }); + const service = await server.listen(); + + const resource = new Resource( + new Cache(), + new State(), + service.options, + ); + const strm = resource.stream(new HttpIncoming({ headers })); + const items = []; + + strm.once('beforeStream', (beforeStream) => { + items.push(beforeStream); + }); + strm.on('data', (data) => { + items.push(data.toString()); + }); + + await getStream(strm); + + t.same(items[0].css, [ + { type: 'text/css', value: 'http://fakecss.com' }, + ]); + t.same(items[0].js, [{ type: 'default', value: 'http://fakejs.com' }]); + t.equal(items[1], '

content component

'); + + await server.close(); + t.end(); + }, +); + +// +// .refresh() +// + +tap.test('resource.refresh() - should return a promise', async (t) => { + const server = new PodletServer({ version: '1.0.0' }); + const service = await server.listen(); + + const resource = new Resource(new Cache(), new State(), service.options); + const refresh = resource.refresh(); + t.ok(refresh instanceof Promise); + + await refresh; + + await server.close(); + t.end(); +}); + +tap.test( + 'resource.refresh() - manifest is available - should return "true"', + async (t) => { + const server = new PodletServer({ version: '1.0.0' }); + const service = await server.listen(); + + const client = new Client({ name: 'podiumClient' }); + const component = client.register(service.options); + + const result = await component.refresh(); + + t.equal(result, true); + + await server.close(); + t.end(); + }, +); + +tap.test( + 'resource.refresh() - manifest is NOT available - should return "false"', + async (t) => { + const client = new Client({ name: 'podiumClient' }); + + const component = client.register({ + name: 'component', + uri: 'http://does.not.exist.finn.no/manifest.json', + }); + + const result = await component.refresh(); + + t.equal(result, false); + t.end(); + }, +); + +tap.test( + 'resource.refresh() - manifest with fallback is available - should get manifest and fallback, but not content', + async (t) => { + const server = new PodletServer({ version: '1.0.0' }); + const service = await server.listen(); + + const client = new Client({ name: 'podiumClient' }); + const component = client.register(service.options); + + await component.refresh(); + + t.equal(server.metrics.manifest, 1); + t.equal(server.metrics.fallback, 1); + t.equal(server.metrics.content, 0); + + await server.close(); + t.end(); + }, +); + +// +// .uri +// + +tap.test( + 'Resource().uri - instantiate new resource object - expose own uri', + (t) => { + const resource = new Resource(new Cache(), new State(), { + uri: URI, + name: 'someName', + clientName: 'someName', + timeout: 1000, + maxAge: Infinity, + }); + t.equal(resource.uri, URI); + t.end(); + }, +); + +// +// .name +// + +tap.test( + 'Resource().name - instantiate new resource object - expose own name', + (t) => { + const resource = new Resource(new Cache(), new State(), { + uri: URI, + name: 'someName', + clientName: 'someName', + timeout: 1000, + maxAge: Infinity, + }); + t.equal(resource.name, 'someName'); + t.end(); + }, +); diff --git a/tests/response.test.js b/tests/response.test.js new file mode 100644 index 00000000..9e455135 --- /dev/null +++ b/tests/response.test.js @@ -0,0 +1,213 @@ +import { AssetCss, AssetJs } from '@podium/utils'; +import tap from 'tap'; +import Response from '../lib/response.js'; + +tap.test('Response() - object tag - should be PodiumClientResponse', (t) => { + const response = new Response(); + t.equal( + Object.prototype.toString.call(response), + '[object PodiumClientResponse]', + ); + t.end(); +}); + +tap.test( + 'Response() - no arguments - should set "content" to empty Sting', + (t) => { + const response = new Response(); + t.equal(response.content, ''); + t.end(); + }, +); + +tap.test( + 'Response() - no arguments - should set "content" to empty Sting', + (t) => { + const response = new Response(); + t.equal(response.content, ''); + t.end(); + }, +); + +tap.test( + 'Response() - no arguments - should set "headers" to empty Object', + (t) => { + const response = new Response(); + t.same(response.headers, {}); + t.end(); + }, +); + +tap.test('Response() - no arguments - should set "css" to empty Array', (t) => { + const response = new Response(); + t.same(response.css, []); + t.end(); +}); + +tap.test('Response() - no arguments - should set "js" to empty Array', (t) => { + const response = new Response(); + t.same(response.js, []); + t.end(); +}); + +tap.test( + 'Response() - no arguments - should return default values when calling toJSON()', + (t) => { + const response = new Response(); + t.same(response.toJSON(), { + content: '', + headers: {}, + css: [], + js: [], + redirect: null, + }); + t.end(); + }, +); + +tap.test( + 'Response() - no arguments - should return default content value when calling toString()', + (t) => { + const response = new Response(); + t.same(response.toString(), ''); + t.end(); + }, +); + +tap.test( + 'Response() - "content" argument has a value - should set value on "content"', + (t) => { + const response = new Response({ content: 'foo' }); + t.equal(response.content, 'foo'); + t.end(); + }, +); + +tap.test( + 'Response() - "headers" argument has a value - should set value on "headers"', + (t) => { + const response = new Response({ headers: { foo: 'bar' } }); + t.same(response.headers, { foo: 'bar' }); + t.end(); + }, +); + +tap.test( + 'Response() - "css" argument has a value - should set value on "css"', + (t) => { + const asset = new AssetCss({ value: 'foo.css' }); + const response = new Response({ + css: [asset], + }); + t.same(response.css, [asset]); + t.end(); + }, +); + +tap.test( + 'Response() - "js" argument has a value - should set value on "js"', + (t) => { + const asset = new AssetJs({ value: 'foo.js' }); + const response = new Response({ js: [asset] }); + t.same(response.js, [asset]); + t.end(); + }, +); + +tap.test( + 'Response() - arguments is set - should return set values when calling toJSON()', + (t) => { + const css = new AssetCss({ value: 'foo.css' }); + const js = new AssetJs({ value: 'foo.js' }); + + const response = new Response({ + content: 'foo', + headers: { foo: 'bar' }, + css: [css], + js: [js], + redirect: { + statusCode: 302, + location: 'http://redirects.are.us.com', + }, + }); + t.same(response.toJSON(), { + content: 'foo', + headers: { foo: 'bar' }, + css: [css], // TODO: should we also call .toJSON on the contents of the array? + js: [js], // TODO: should we also call .toJSON on the contents of the array? + redirect: { + statusCode: 302, + location: 'http://redirects.are.us.com', + }, + }); + t.end(); + }, +); + +tap.test( + 'Response() - arguments is set - should return set content value when calling toString()', + (t) => { + const css = new AssetCss({ value: 'foo.css' }); + const js = new AssetJs({ value: 'foo.js' }); + const response = new Response({ + content: 'foo', + headers: { foo: 'bar' }, + css: [css], + js: [js], + }); + t.equal(response.toString(), 'foo'); + t.end(); + }, +); + +tap.test( + 'Response() - use Object in String literal - should use value of set content', + (t) => { + const css = new AssetCss({ value: 'foo.css' }); + const js = new AssetJs({ value: 'foo.js' }); + const response = new Response({ + content: 'foo', + headers: { foo: 'bar' }, + css: [css], + js: [js], + }); + t.equal(`bar ${response}`, 'bar foo'); + t.end(); + }, +); + +tap.test( + 'Response() - concatinate Object with other String - should use value of set content', + (t) => { + const css = new AssetCss({ value: 'foo.css' }); + const js = new AssetJs({ value: 'foo.js' }); + const response = new Response({ + content: 'foo', + headers: { foo: 'bar' }, + css: [css], + js: [js], + }); + t.equal(`bar ${response}`, 'bar foo'); + t.end(); + }, +); + +tap.test( + 'Response() - JSON.stringify object - should return JSON string object', + (t) => { + const css = new AssetCss({ value: 'foo.css' }); + const js = new AssetJs({ value: 'foo.js' }); + const response = new Response({ + content: 'foo', + headers: { foo: 'bar' }, + css: [css], + js: [js], + redirect: null, + }); + t.equal( + JSON.stringify(response), + '{"redirect":null,"content":"foo","headers":{"foo":"bar"},"css":[{"value":"foo.css","type":"text/css","rel":"stylesheet"}],"js":[{"value":"foo.js","type":"default"}]}', + ); + t.end(); + }, +); diff --git a/test/state.js b/tests/state.test.js similarity index 100% rename from test/state.js rename to tests/state.test.js diff --git a/test/utils.js b/tests/utils.test.js similarity index 100% rename from test/utils.js rename to tests/utils.test.js diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..564be4fc --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "lib": ["es2020"], + "module": "nodenext", + "target": "es2020", + "resolveJsonModule": true, + "checkJs": true, + "allowJs": true, + "moduleResolution": "nodenext", + "declaration": true, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "outDir": "types" + }, + "include": ["./lib/**/*.js"] +} diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 00000000..801ba2e6 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "include": ["./tests/**/*.js"], + "compilerOptions": { + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true + } +}