From fc64da27b5203171f409db097caaaec95980e3ca Mon Sep 17 00:00:00 2001 From: "Gregor \"hrax\" Magdolen" Date: Mon, 12 Aug 2024 01:49:12 +0200 Subject: [PATCH] Fix naming conventions, tests + config, proper jest-when checks --- jest.config.ts | 6 +- package-lock.json | 23 ++ package.json | 1 + src/core/OAuthClient.ts | 14 +- src/core/ProfileManager.ts | 8 +- src/core/RESTClient.ts | 23 +- src/core/UpdateXML.ts | 0 src/core/_Request.js | 197 ------------------ src/core/sn.ts | 81 ++++++- src/linter/{Linter.js => Linter.ts} | 90 +++----- src/linter/UpdateXMLScan.ts | 37 ++++ ...uthClient.spec.tsx => OAuthClient.spec.ts} | 4 +- ...anager.spec.tsx => ProfileManager.spec.ts} | 7 +- ...RESTClient.spec.tsx => RESTClient.spec.ts} | 72 ++++++- .../{Request.spec.tsx => Request.spec.ts} | 0 src/spec/helpers.ts | 5 + src/util/RestHelper.js | 42 ---- src/util/XPathHelper.js | 22 -- src/util/helpers.ts | 33 +++ src/util/index.js | 13 -- src/util/template.js | 11 - tsconfig.json | 2 +- 22 files changed, 304 insertions(+), 387 deletions(-) delete mode 100644 src/core/UpdateXML.ts delete mode 100644 src/core/_Request.js rename src/linter/{Linter.js => Linter.ts} (70%) create mode 100644 src/linter/UpdateXMLScan.ts rename src/spec/core/{OAuthClient.spec.tsx => OAuthClient.spec.ts} (98%) rename src/spec/core/{ProfileManager.spec.tsx => ProfileManager.spec.ts} (96%) rename src/spec/core/{RESTClient.spec.tsx => RESTClient.spec.ts} (70%) rename src/spec/core/{Request.spec.tsx => Request.spec.ts} (100%) create mode 100644 src/spec/helpers.ts delete mode 100644 src/util/RestHelper.js delete mode 100644 src/util/XPathHelper.js create mode 100644 src/util/helpers.ts delete mode 100644 src/util/index.js delete mode 100644 src/util/template.js diff --git a/jest.config.ts b/jest.config.ts index 5913b78..98ffb52 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -104,6 +104,8 @@ const config: JestConfigWithTsJest = { // An enum that specifies notification mode. Requires { notify: true } // notifyMode: "failure-change", + // onlyFailures: true, + // A preset that is used as a base for Jest's configuration // preset: undefined, @@ -111,7 +113,9 @@ const config: JestConfigWithTsJest = { // projects: undefined, // Use this configuration option to add custom reporters to Jest - // reporters: undefined, + // reporters: [ + // "summary" + // ], // Automatically reset mock state before every test // resetMocks: false, diff --git a/package-lock.json b/package-lock.json index 6eee8ae..260d774 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@babel/core": "^7.25.2", "@babel/eslint-parser": "^7.25.1", "@jest/globals": "^29.7.0", + "@types/eslint": "^9.6.0", "@types/jest": "^29.5.12", "@types/jest-when": "^3.5.5", "@types/node": "^22.0.0", @@ -1700,6 +1701,22 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/eslint": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.0.tgz", + "integrity": "sha512-gi6WQJ7cHRgZxtkQEoyHMppPjq9Kxo5Tjn2prSKDSmZrCz8TZ3jSRCeTJm+WoM+oB0WG37bRqLzaaU3q7JypGg==", + "dev": true, + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "dev": true + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -1752,6 +1769,12 @@ "@types/jest": "*" } }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, "node_modules/@types/node": { "version": "22.1.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.1.0.tgz", diff --git a/package.json b/package.json index 0eb2cfc..b32fbeb 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "@babel/core": "^7.25.2", "@babel/eslint-parser": "^7.25.1", "@jest/globals": "^29.7.0", + "@types/eslint": "^9.6.0", "@types/jest": "^29.5.12", "@types/jest-when": "^3.5.5", "@types/node": "^22.0.0", diff --git a/src/core/OAuthClient.ts b/src/core/OAuthClient.ts index 6a6ddc5..59ae0c6 100644 --- a/src/core/OAuthClient.ts +++ b/src/core/OAuthClient.ts @@ -3,7 +3,7 @@ import { URL, URLSearchParams } from "url"; import { Request, Response } from "./Request.js"; import { RequestOptions } from "https"; import { Package } from "./Package.js"; -import { SNOAuthToken } from "./sn.js"; +import { SNOAuthTokenData } from "./sn.js"; import { InstanceAuthenticationData, InstanceConfig, InstanceOAuthTokenData, Profile, ProfileManager } from "./ProfileManager.js"; const CLIENT_BASE_URL: string = "/oauth_entity.do"; @@ -94,7 +94,7 @@ export class OAuthClient { return url; } - async requestTokenByCode(profile: Profile, code: string): Promise { + async requestTokenByCode(profile: Profile, code: string): Promise { const url: URL = new URL(TOKEN_BASE_URL, profile.getBaseUrl()); const body: URLSearchParams = new URLSearchParams(); @@ -112,7 +112,7 @@ export class OAuthClient { } }; - const token: SNOAuthToken = await Request.json(url, options, body.toString()) + const token: SNOAuthTokenData = await Request.json(url, options, body.toString()) .catch((reason: any) => { if (reason instanceof Response) { const response: Response = reason; @@ -127,7 +127,7 @@ export class OAuthClient { return token; } - async requestTokenByUsername(profile: Profile, username: string, password: string): Promise { + async requestTokenByUsername(profile: Profile, username: string, password: string): Promise { const url: URL = new URL(TOKEN_BASE_URL, profile.getBaseUrl()); const body: URLSearchParams = new URLSearchParams(); @@ -145,7 +145,7 @@ export class OAuthClient { } }; - const token: SNOAuthToken = await Request.json(url, options, body.toString()) + const token: SNOAuthTokenData = await Request.json(url, options, body.toString()) .catch((reason: any) => { if (reason instanceof Response) { const response: Response = reason; @@ -160,7 +160,7 @@ export class OAuthClient { return token; } - async refreshToken(profile: Profile): Promise { + async refreshToken(profile: Profile): Promise { const url: URL = new URL(TOKEN_BASE_URL, profile.getBaseUrl()); const body: URLSearchParams = new URLSearchParams(); @@ -177,7 +177,7 @@ export class OAuthClient { } }; - const token: SNOAuthToken = await Request.json(url, options, body.toString()) + const token: SNOAuthTokenData = await Request.json(url, options, body.toString()) .catch((reason: any) => { if (reason instanceof Response) { const response: Response = reason; diff --git a/src/core/ProfileManager.ts b/src/core/ProfileManager.ts index fbaea28..11f3670 100644 --- a/src/core/ProfileManager.ts +++ b/src/core/ProfileManager.ts @@ -3,7 +3,7 @@ import os from "os"; import path from "path"; import { RESTClient } from "./RESTClient.js"; -import { SNOAuthToken, SNTable } from "./sn.js"; +import { SNOAuthTokenData, SNTable } from "./sn.js"; class Constants { static readonly PROFILES_FOLDER_NAME = ".now-eslint-profiles"; @@ -145,11 +145,11 @@ export class Profile { return this.config.auth; } - getToken(): SNOAuthToken | null | undefined { + getToken(): SNOAuthTokenData | null | undefined { return this.config.auth.token; } - refreshToken(token: SNOAuthToken) { + refreshToken(token: SNOAuthTokenData) { this.config.auth.lastRetrieved = Date.now(); this.config.auth.token = token; } @@ -178,7 +178,7 @@ export interface InstanceOAuthTokenData { clientID: string; clientSecret: string; lastRetrieved: number; - token?: SNOAuthToken | null; + token?: SNOAuthTokenData | null; } export interface InstanceUserData { diff --git a/src/core/RESTClient.ts b/src/core/RESTClient.ts index 0782e8e..a80e74a 100644 --- a/src/core/RESTClient.ts +++ b/src/core/RESTClient.ts @@ -3,6 +3,7 @@ import { OAuthClient } from "./OAuthClient.js"; import { Request, Response } from "./Request.js"; import { Package } from "./Package.js"; import { InstanceConfig, Profile, TableConfig } from "./ProfileManager.js"; +import { SNUpdateXMLData } from "./sn.js"; export class RESPONSE_STATUS { static readonly OK = 200 as const; @@ -227,7 +228,7 @@ export class RESTClient { return pref; } - async loadUpdateXMLByUpdateSetIds(profile: Profile, ...ids: string[]): Promise { + async loadUpdateXMLByUpdateSetIds(profile: Profile, ...ids: string[]): Promise> { if (ids.length === 0) { ids.push("-1"); } @@ -249,9 +250,7 @@ export class RESTClient { await this.oauthClient.handleAuthentication(profile, options); - const response: JSONRESTResponse = await Request.json(url, options); - - + return (> await Request.json(url, options)).result; } async loadUpdateXMLByUpdateSetQuery(profile: Profile, ids: string): Promise { @@ -269,22 +268,6 @@ export interface TableParentData { "super_class.name": string; } -export interface UpdateXMLData { - sys_id: string; - name: string; - action: string; - type: "INSERT_OR_UPDATE" | "DELETE"; - target_name: string; - update_set: string; - - sys_created_on: string; - sys_created_by: string; - sys_updated_on: string; - sys_updated_by: string; - payload: string; - -} - export interface JSONRESTResponse { result: Array } \ No newline at end of file diff --git a/src/core/UpdateXML.ts b/src/core/UpdateXML.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/core/_Request.js b/src/core/_Request.js deleted file mode 100644 index 50d6660..0000000 --- a/src/core/_Request.js +++ /dev/null @@ -1,197 +0,0 @@ -/* eslint-disable no-console, max-len */ -const http = require("https"); -const HttpsProxyAgent = require("https-proxy-agent"); -const Assert = require("../util/Assert.js"); - -const URL_PATH_SEPARATOR = "/"; - -/** - * Base URL to load the update sets ordered descending by created on field - */ -const TEST_CONNECTION_PATH = "/api/now/table/sys_update_set?sysparm_display_value=false&sysparm_exclude_reference_link=true&sysparm_fields=sys_id&sysparm_query=ORDERBYDESCsys_created_on^name=Default&sysparm_limit=1"; - -const RESPONSE_STATUS = { - OK: 200, - NOT_FOUND: 400, - UNAUTHORIZED: 401, - ERROR: 500 -}; - -/** - * See https://support.servicenow.com/kb?id=kb_article_view&sysparm_article=KB0534905 - * In the REST world, PUT and PATCH have different semantics. PUT means replace the entire resource with given data - * (so null out fields if they are not provided in the request), while PATCH means replace only specified fields. - * For the Table API, however, PUT and PATCH mean the same thing. PUT and PATCH modify only the fields specified in the request. - */ -class Request { - /** - * Create new instance of NowRequest to load the data from the Service Now instance - */ - constructor(options) { - const extended = Object.assign({ - proxy: null, - domain: "", - username: "", - password: "", - encoding: "utf8", - timeout: 10000, - rejectEmptyResponse: true, - headers: { - "Accept": "application/json", - "Content-Type": "application/json", - "User-Agent": "X-Now-ESLint" - } - }, options || {}); - - Assert.notEmpty(extended.domain, "Service Now domain cannot be empty!"); - Assert.notEmpty(extended.username, "Service Now username cannot be empty!"); - Assert.notEmpty(extended.password, "Service Now password cannot be empty!"); - - // Cleanup domain; remove ending SEPARATOR - if (extended.domain.endsWith(URL_PATH_SEPARATOR)) { - extended.domain = extended.domain.slice(0, -1); - } - - Object.freeze(extended); - Object.freeze(extended.headers); - - Object.defineProperty(this, "options", { - writable: false, - configurable: false, - enumerable: true, - value: extended - }); - } - - request(path, method, headers, body) { - Assert.notEmpty(path, "Provided path to fetch cannot be empty!"); - Assert.notEmpty(method, "Provided method cannot be empty!"); - // TODO: Check method is one of ? - Assert.isOneOf(method, ["GET", "POST", "PATCH", "DELETE", "PUT"], "Provided method '{0}' needs to be one of '{1}'!"); - - // Cleanup path; add starting SEPARATOR - if (path && !path.startsWith(URL_PATH_SEPARATOR)) { - path = URL_PATH_SEPARATOR + path; - } - - const options = { - "auth": [this.options.username, this.options.password].join(":"), - "method": method, - "headers": Object.assign({}, this.options.headers, headers || {}), - "rejectUnauthorized": false, - "timeout": this.options.timeout - }; - - if (this.options.proxy != null && this.options.proxy !== "") { - options.agent = new HttpsProxyAgent(this.options.proxy); - } - - return new Promise((resolve, reject) => { - const request = http.request( - this.options.domain + path, - options, - (response) => { - let data = ""; - response.setEncoding(this.options.encoding); - - response.on("data", (chunk) => { - // If response status code is not 200 we are not processing any data from the response body - if (response.statusCode !== RESPONSE_STATUS.OK) { - return; - } - data += chunk; - }); - - response.on("end", () => { - // If response status code is not 200 resolve reject promise with an error - if (response.statusCode !== RESPONSE_STATUS.OK) { - reject(new Error(`Received response status code is '${response.statusCode}' expected '${RESPONSE_STATUS.OK}' on path '${path}'`), response); - return; - } - - // If response body is empty resolve reject promise with an error - if (this.options.rejectEmptyResponse && (data == null || data === "")) { - reject(new Error("Received response body is empty"), response); - return; - } - - // TODO: check unauthorized access, http reponse or no access allowed responses before triggering success - resolve(data); - }); - } - ); - - request.on("error", (e) => { - reject(new Error(e)); - }); - - if (body != null) { - request.write(body, this.options.encoding); - } - - request.end(); - }); - } - - /** - * Retrieves data from the specified {@code path} using GET method request. Path is appended to the domain specified in {@code this.options.domain} - * - * @param {String} path the path to retrieve data from - * @param {Object} headers the headers to be used to override the global configuration just for this request - * @return {Promise} The Promise will be rejected with error in case of: reponse status code is not 200 or response is empty - */ - async get(path, headers) { - return await this.request(path, "GET", headers); - } - - async post(path, headers, body) { - return await this.request(path, "POST", headers, body); - } - - async patch(path, headers, body) { - return await this.request(path, "PATCH", headers, body); - } - - async delete(path, headers) { - return await this.request(path, "DELETE", headers); - } - - /** - * Retrieves data from the specified {@code path} using GET method request. Path is appended to the domain specified in {@code this.options.domain} - * Request is not via forced "application/json" header, only response is expected to be a JSON object. - * - * @param {String} path the path to retrieve data from - * @return {Object} parsed JSON object - * @throws {Error} when fetched response cannot be parsed into JSON - */ - async json(path) { - const data = await this.get(path); - try { - return JSON.parse(data); - } catch (e) { - throw new Error("Unable to parse response data to JSON."); - } - } - - /** - * Test the connection to the configured instance by loading one update set with name=Default. - * Even per mutiple application scopes there should be at least one update set wiuth given name. - * @returns {boolean} true if connection was sucessfull and we managed to load the details, false if something above failed - * @throws {Error} if an error occurs during the connection - */ - async testConnection() { - try { - // Load one update set named default; every instance should have one - const response = await this.json(TEST_CONNECTION_PATH); - if (response.result && response.result.length && response.result[0].sys_id) { - // We have a result of a single record with a sys_id, assume connection is OK - return true; - } - } catch (err) { - throw new Error("An error occured during connection test."); - } - return false; - } -} - -module.exports = Request; \ No newline at end of file diff --git a/src/core/sn.ts b/src/core/sn.ts index fc3475d..6f2e53b 100644 --- a/src/core/sn.ts +++ b/src/core/sn.ts @@ -1,4 +1,8 @@ -export interface SNOAuthToken { +import { DOMParser } from "@xmldom/xmldom"; +import * as xpath from "xpath"; +import { xmlhelpers } from "../util/helpers"; + +export interface SNOAuthTokenData { access_token: string; refresh_token: string; scope: string; @@ -22,4 +26,79 @@ export interface SNTable { label?: string; parent?: string; fields: {[key: string]: SNField} +} + +export type SNUpdateXMLAction = "INSERT_OR_UPDATE" | "DELETE"; + +// JSON Data loaded via REST API +export interface SNUpdateXMLData { + sys_id: string; + name: string; + application: string; + action: SNUpdateXMLAction; + type: string; + target_name: string; + update_set: string; + + sys_created_on: string; + sys_created_by: string; + sys_updated_on: string; + sys_updated_by: string; + sys_mod_count: number; + payload: string; + payloadHash: number; +} + +export class SNUpdateXML { + ID: string = "-1"; + name: string = ""; + application: string = "global"; + action: SNUpdateXMLAction = "INSERT_OR_UPDATE"; + type: string = ""; + targetName: string = ""; + updateSetID: string = ""; + + createdOn: string = ""; + createdBy: string = ""; + updatedOn: string = ""; + updatedBy: string = ""; + updates: number = 0; + + payload: string = ""; + payloadHash: number = 0; + + targetTable: string = ""; + targetID: string = "-1"; + + constructor(data?: SNUpdateXMLData) { + if (data != null) { + this.ID = data.sys_id; + this.name = data.name; + this.application = data.application; + this.action = data.action; + this.type = data.type; + this.targetName = data.target_name; + this.updateSetID = data.update_set; + this.createdOn = data.sys_created_by + this.createdBy = data.sys_created_by; + this.updatedOn = data.sys_updated_on; + this.updatedBy = data.sys_updated_by; + this.updates = this.updates + data.sys_mod_count; + + this.payload = data.payload; + this.payloadHash = data.payloadHash; + } + } + + parsePayload(): void { + if (this.payload == null || this.payload === "") { + return; + } + + const document = new DOMParser().parseFromString(this.payload); + this.targetTable = xmlhelpers.parsePayloadTableName(document); + if (this.targetTable !== "") { + this.targetID = xmlhelpers.parsePayloadTableFieldValue(this.targetTable, "sys_id", document); + } + } } \ No newline at end of file diff --git a/src/linter/Linter.js b/src/linter/Linter.ts similarity index 70% rename from src/linter/Linter.js rename to src/linter/Linter.ts index d28e4c8..6794b27 100644 --- a/src/linter/Linter.js +++ b/src/linter/Linter.ts @@ -1,82 +1,50 @@ /* eslint-disable */ -const {ESLint} = require("eslint"); +import { ESLint } from "eslint"; -const Assert = require("../util/Assert.js"); -const HashHelper = require("../util/HashHelper.js"); -const RESTHelper = require("../util/RestHelper.js"); -const XPathHelper = require("../util/XPathHelper.js"); -const UpdateXMLScan = require("./UpdateXMLScan.js"); +import Assert from "../util/Assert.js"; +import HashHelper from "../util/HashHelper.js"; +import { UpdateXMLScan } from "./UpdateXMLScan.js"; // const PDFReportGenerator = require("../generator/PDFReportGenerator.js"); // const JSONReportGenerator = require("../generator/JSONReportGenerator.js"); -const AbstractReportGenerator = require("../generator/AbstractReportGenerator.js"); +import AbstractReportGenerator from "../generator/AbstractReportGenerator.js"; +import { Profile } from "../core/ProfileManager.js"; +import { escape } from "querystring"; +import { RESTClient } from "../core/RESTClient.js"; + +export interface LinterOptions { + title: string; + query: string; +}; + +export class Linter { + + private profile: Profile; + private options: LinterOptions; + private client: RESTClient; + private eslint: ESLint = new ESLint(); + private changes: Map = new Map(); + private metrics: Map = new Map(); -class Linter { /** * * @param {Profile} profile * @param {Object} options */ - constructor(profile, options) { - Assert.notNull(options, "Options must be specified."); - Assert.notEmpty(options.title, "Title in options needs to be specified."); - Assert.notEmpty(options.query, "Query in options needs to be specified."); - - Object.defineProperty(this, "options", { - writable: false, - configurable: false, - enumerable: true, - value: Object.assign({ - "query": "", - "title": "Service Now ESLint Report" - }, options || {}) - }); - Object.freeze(this.options); - - Object.defineProperty(this, "changes", { - writable: false, - configurable: false, - enumerable: true, - value: new Map() - }); - - Object.defineProperty(this, "metrics", { - writable: false, - configurable: false, - enumerable: true, - value: new Map() - }); - - Object.defineProperty(this, "profile", { - writable: false, - configurable: false, - enumerable: true, - value: profile - }); - - Object.defineProperty(this, "instance", { - writable: false, - configurable: false, - enumerable: true, - value: this.profile.createInstance() - }); - - Object.defineProperty(this, "eslint", { - writable: false, - configurable: false, - enumerable: true, - value: new ESLint(Object.fromEntries(this.profile.eslint.entries())) - }); + constructor(profile: Profile, client: RESTClient, options: LinterOptions) { + this.profile = profile; + this.client = client; + this.options = options; } /** * Fetch update set changes from the instance. Resets loaded changes & metrics on each call! * @returns {void} */ - async fetch() { + async fetch(): Promise { this.changes.clear(); this.metrics.clear(); - const response = await this.instance.requestUpdateXMLByUpdateSetQuery(this.options.query); + /* const response = await this.client.requestUpdateXMLByUpdateSetQuery(this.options.query); // Get records from the response response.result.forEach((record) => { @@ -87,7 +55,7 @@ class Linter { } else { this.changes.get(scan.name).incrementUpdateCount(); } - }); + }); */ } /** diff --git a/src/linter/UpdateXMLScan.ts b/src/linter/UpdateXMLScan.ts new file mode 100644 index 0000000..6709970 --- /dev/null +++ b/src/linter/UpdateXMLScan.ts @@ -0,0 +1,37 @@ +import { SNUpdateXML, SNUpdateXMLData } from "../core/sn"; + +export type UpdateXMLScanStatus = + // Do not lint (deleted record) + "DELETED" | + + // Do not lint (not configured table) + "IGNORED" | + + // Do not lint, should be checked manually (has no fields to check, but still configured) + "MANUAL" | + + // JSON payload initialized can be scanned based on configuration + "SCAN" | + + // JSON payload detected as inactive; mark, do not lint + "INACTIVE" | + + // Should be linted but does not contain anything to lint + "SKIPPED" | + + // Linted, at least one error found + "ERROR" | + + // Linted, at least one warning found + "WARNING" | + + // Linted, no warnings or erros found + "OK"; + +export class UpdateXMLScan extends SNUpdateXML { + + constructor(data?: SNUpdateXMLData) { + super(data); + } + +} \ No newline at end of file diff --git a/src/spec/core/OAuthClient.spec.tsx b/src/spec/core/OAuthClient.spec.ts similarity index 98% rename from src/spec/core/OAuthClient.spec.tsx rename to src/spec/core/OAuthClient.spec.ts index 5e3d326..45970ec 100644 --- a/src/spec/core/OAuthClient.spec.tsx +++ b/src/spec/core/OAuthClient.spec.ts @@ -2,7 +2,7 @@ import { OAuthClient, OAuthCodeExpired, OAuthRefreshTokenExpired, OAuthUsernameP import { InstanceConfig, InstanceOAuthTokenData, Profile } from "../../core/ProfileManager"; import { Request, Response } from "../../core/Request"; import { RequestOptions } from "https"; -import { SNOAuthToken } from "../../core/sn"; +import { SNOAuthTokenData } from "../../core/sn"; describe("OAuthClientSpec", () => { const config: InstanceConfig = { @@ -136,7 +136,7 @@ describe("OAuthClientSpec", () => { }); it("should extend headers with authentication", async() => { - const token: SNOAuthToken = config.auth.token!; + const token: SNOAuthTokenData = config.auth.token!; const options: RequestOptions = { method: "GET" }; diff --git a/src/spec/core/ProfileManager.spec.tsx b/src/spec/core/ProfileManager.spec.ts similarity index 96% rename from src/spec/core/ProfileManager.spec.tsx rename to src/spec/core/ProfileManager.spec.ts index bbfbd09..0cb57a7 100644 --- a/src/spec/core/ProfileManager.spec.tsx +++ b/src/spec/core/ProfileManager.spec.ts @@ -4,6 +4,7 @@ import { InstanceConfig, Profile, ProfileInfo, ProfileManager, TableConfig } fro import { RESTClient } from "../../core/RESTClient"; import { OAuthClient } from "../../core/OAuthClient"; import { when } from "jest-when"; +import { jesthelpers } from "../helpers"; describe("ProfileManagerSpec", () => { const dev1Profile = { @@ -53,14 +54,17 @@ describe("ProfileManagerSpec", () => { jest.spyOn(ProfileManager, "pathFor").mockImplementation((name, file) => `${profilesHomePath}/${name}/${file}`); when(jest.spyOn(fs, "readdirSync")) - // @ts-ignore + .defaultImplementation(jesthelpers.defaultWhenImplementationThrow) + // @ts-ignore .calledWith(profilesHomePath).mockReturnValue(["dev1", "dev2"]); + // @ts-ignore jest.spyOn(fs, "statSync").mockImplementation((path) => { return dirStats; }) jest.spyOn(fs, "existsSync").mockReturnValue(true); when(jest.spyOn(fs, "readFileSync")) + .defaultImplementation(jesthelpers.defaultWhenImplementationThrow) .calledWith(`${profilesHomePath}/dev1/profile.json`, "utf8").mockReturnValue(JSON.stringify(expected[0])) .calledWith(`${profilesHomePath}/dev2/profile.json`, "utf8").mockReturnValue(JSON.stringify(expected[1])); @@ -90,6 +94,7 @@ describe("ProfileManagerSpec", () => { jest.spyOn(fs, "existsSync").mockReturnValue(true); when(jest.spyOn(fs, "readFileSync")) + .defaultImplementation(jesthelpers.defaultWhenImplementationThrow) .calledWith(`${profilesHomePath}/${profileName}/profile.json`, "utf8").mockReturnValue(JSON.stringify(dev1Profile)); // need to stub profile.loadTableConfiguration... diff --git a/src/spec/core/RESTClient.spec.tsx b/src/spec/core/RESTClient.spec.ts similarity index 70% rename from src/spec/core/RESTClient.spec.tsx rename to src/spec/core/RESTClient.spec.ts index 9bcfe0a..cea044c 100644 --- a/src/spec/core/RESTClient.spec.tsx +++ b/src/spec/core/RESTClient.spec.ts @@ -1,9 +1,11 @@ import { when } from "jest-when"; -import { OAuthClient } from "../../core/OAuthClient"; -import { InstanceConfig, Profile, TableConfig } from "../../core/ProfileManager"; -import { Request, Response } from "../../core/Request"; -import { RESTClient, JSONRESTResponse, TableAPI, TableFieldData, TableParentData } from "../../core/RESTClient"; +import { jesthelpers } from "../helpers.js"; import { URLSearchParams } from "url"; +import { OAuthClient } from "../../core/OAuthClient.js"; +import { InstanceConfig, Profile, TableConfig } from "../../core/ProfileManager.js"; +import { Request, Response } from "../../core/Request.js"; +import { RESTClient, JSONRESTResponse, TableAPI, TableFieldData, TableParentData } from "../../core/RESTClient.js"; +import { SNUpdateXMLData } from "../../core/sn.js"; describe("RESTClientSpec", () => { const config: InstanceConfig = { @@ -69,6 +71,7 @@ describe("RESTClientSpec", () => { jest.spyOn(response, "isOK").mockReturnValue(true); when(requestExecuteSpy) + .defaultImplementation(jesthelpers.defaultWhenImplementationThrow) .calledWith(expect.objectContaining(url), expect.anything(), undefined).mockResolvedValue(response); const client = new RESTClient(oauthClient); @@ -83,6 +86,7 @@ describe("RESTClientSpec", () => { jest.spyOn(response, "isOK").mockReturnValue(true); when(requestExecuteSpy) + .defaultImplementation(jesthelpers.defaultWhenImplementationThrow) .calledWith(expect.objectContaining(url), expect.anything(), undefined).mockResolvedValue(response); const client = new RESTClient(oauthClient); @@ -177,6 +181,7 @@ describe("RESTClientSpec", () => { const requestExecuteSpy = jest.spyOn(Request, "execute"); when(requestExecuteSpy) + .defaultImplementation(jesthelpers.defaultWhenImplementationThrow) // pull table-parent .calledWith(expect.objectContaining(tpUrl), expect.anything(), undefined).mockResolvedValue(tpResponse) // pull table-field @@ -192,4 +197,63 @@ describe("RESTClientSpec", () => { expect(requestExecuteSpy).toHaveBeenCalledTimes(3); }); }); + + describe("loading update set changes", () => { + it("should load by update set ids", async() => { + const responseBody: Array = [ + { + action: "INSERT_OR_UPDATE", + application: "global", + name: "name1", + payload: "", + payloadHash: 0, + sys_created_by: "admin", + sys_created_on: "1970-01-01 00:00:00", + sys_id: "-1", + sys_mod_count: 2, + sys_updated_by: "admin", + sys_updated_on: "1970-01-02 00:00:00", + target_name: "target1", + type: "Script Include", + update_set: "1" + }, + { + action: "INSERT_OR_UPDATE", + application: "global", + name: "name2", + payload: "", + payloadHash: 0, + sys_created_by: "admin", + sys_created_on: "1970-01-01 00:00:00", + sys_id: "-2", + sys_mod_count: 2, + sys_updated_by: "admin", + sys_updated_on: "1970-01-02 00:00:00", + target_name: "target2", + type: "Script Include", + update_set: "2" + } + ]; + const url = { + origin: profile.getBaseUrl(), + pathname: TableAPI.UPDATE_XML_PATH, + search: expect.stringContaining(encodeURIComponent("update_setIN1,2,3")) + }; + + const response = Response.empty(JSON.stringify(_makeRESTResponse(responseBody))); + jest.spyOn(response, "isEmpty").mockReturnValue(false); + jest.spyOn(response, "isOK").mockReturnValue(true); + + const executeSpy = jest.spyOn(Request, "execute"); + when(executeSpy) + .defaultImplementation(jesthelpers.defaultWhenImplementationThrow) + .calledWith(expect.objectContaining(url), expect.anything(), undefined).mockResolvedValue(response); + + const client = new RESTClient(oauthClient); + await expect(client.loadUpdateXMLByUpdateSetIds(profile, "1","2","3")).resolves.toStrictEqual(responseBody); + }); + + it.todo("should load by update set query"); + }); + }); \ No newline at end of file diff --git a/src/spec/core/Request.spec.tsx b/src/spec/core/Request.spec.ts similarity index 100% rename from src/spec/core/Request.spec.tsx rename to src/spec/core/Request.spec.ts diff --git a/src/spec/helpers.ts b/src/spec/helpers.ts new file mode 100644 index 0000000..47cc760 --- /dev/null +++ b/src/spec/helpers.ts @@ -0,0 +1,5 @@ +export namespace jesthelpers { + export function defaultWhenImplementationThrow(...args: any[]): any { + throw new Error(`Unmatched call; args: ${JSON.stringify(args, null, 2)}`); + } +} \ No newline at end of file diff --git a/src/util/RestHelper.js b/src/util/RestHelper.js deleted file mode 100644 index e16c757..0000000 --- a/src/util/RestHelper.js +++ /dev/null @@ -1,42 +0,0 @@ -const xpath = require("xpath"); -const {DOMParser} = require("@xmldom/xmldom"); - -class RESTHelper { - /** - * No instances - */ - constructor() { - throw new Error("Static class, no instances!"); - } - - static transformUpdateXMLToData(record) { - // type,target_name,update_set,payload - const toReturn = { - name: record.name || null, - sysId: record.sys_id || null, - action: record.action || null, - type: record.type || null, - targetName: record.target_name || null, - updateSet: record.update_set || null, - payload: record.payload || null, - - createdBy: record.sys_created_by || null, - createdOn: record.sys_created_on || null, - updatedBy: record.sys_updated_by || null, - updatedOn: record.sys_updated_on || null - }; - - if (toReturn.payload != null) { - const doc = new DOMParser().parseFromString(toReturn.payload); - const tableElm = xpath.select1("//*/*[1]", doc); - const idElm = xpath.select1("./sys_id/text()", tableElm); - - toReturn.targetTable = tableElm ? tableElm.localName : null; - toReturn.targetId = idElm ? idElm.nodeValue : null; - } - - return toReturn; - } -} - -module.exports = RESTHelper; \ No newline at end of file diff --git a/src/util/XPathHelper.js b/src/util/XPathHelper.js deleted file mode 100644 index 05e7eb1..0000000 --- a/src/util/XPathHelper.js +++ /dev/null @@ -1,22 +0,0 @@ -const xpath = require("xpath"); -const {DOMParser} = require("@xmldom/xmldom"); - -class XPathHelper { - /** - * No instances - */ - constructor() { - throw new Error("Static class, no instances!"); - } - - static parseFieldValue(table, field, payload) { - const doc = new DOMParser().parseFromString(payload); - const data = xpath.select1(`//record_update/${table}/${field}/text()`, doc); - if (data == null) { - return null; - } - return data.nodeValue; - } -} - -module.exports = XPathHelper; \ No newline at end of file diff --git a/src/util/helpers.ts b/src/util/helpers.ts new file mode 100644 index 0000000..776604d --- /dev/null +++ b/src/util/helpers.ts @@ -0,0 +1,33 @@ +import { DOMParser } from "@xmldom/xmldom"; +import * as xpath from "xpath"; + +export namespace xmlhelpers { + + /* export const template = (strings: string, ...keys: string[]) => { + return (...values: string[]) => { + const dict = values[values.length - 1] || {}; + const result = [strings[0]]; + keys.forEach((key: number | string, i) => { + const value = (Number.isInteger(key) ? values[ key] : dict[ key]) || key; + result.push(value, strings[i + 1]); + }); + return result.join(""); + } + */ + + export function parsePayloadTableName(document: Document): string { + const tableName = xpath.select1("string(//*[@table]/@table)", document); + if (tableName == null) { + return ""; + } + return tableName; + } + + export function parsePayloadTableFieldValue(table: string, field: string, document: Document): string { + const data = xpath.select1(`string(//record_update[@table]/${table}/${field}/text())`, document); + if (data == null) { + return ""; + } + return data; + } +}; \ No newline at end of file diff --git a/src/util/index.js b/src/util/index.js deleted file mode 100644 index 812eda5..0000000 --- a/src/util/index.js +++ /dev/null @@ -1,13 +0,0 @@ -const Assert = require("./Assert.js"); -const HashHelper = require("./HashHelper.js"); -const XPathHelper = require("./XPathHelper.js"); -const RESTHelper = require("./RestHelper.js"); -const template = require("./template.js"); - -module.exports = { - Assert, - HashHelper, - RESTHelper, - XPathHelper, - template -}; \ No newline at end of file diff --git a/src/util/template.js b/src/util/template.js deleted file mode 100644 index 30d4ea3..0000000 --- a/src/util/template.js +++ /dev/null @@ -1,11 +0,0 @@ -module.exports = (strings, ...keys) => { - return (...values) => { - const dict = values[values.length - 1] || {}; - const result = [strings[0]]; - keys.forEach((key, i) => { - const value = (Number.isInteger(key) ? values[key] : dict[key]) || key; - result.push(value, strings[i + 1]); - }); - return result.join(""); - }; -}; \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 9974ce6..61141cb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,5 +18,5 @@ "allowSyntheticDefaultImports": true, }, "include": ["./src/**/*"], - "exclude": ["node_modules", "spec", "examples"] + "exclude": ["node_modules", "spec", "examples", "./src/spec"] } \ No newline at end of file