From 212ae453d618b6b8db5b25eeac417a3b727460b4 Mon Sep 17 00:00:00 2001 From: Wvaviator Date: Sun, 11 Feb 2024 17:52:00 -0600 Subject: [PATCH 01/34] update npm scripts - create wrapper script --- npm/bin/capti.js | 18 ++++++++++++++++++ npm/{bin => dist}/.gitignore | 0 npm/package.json | 4 ++-- npm/scripts/download.js | 29 ++++++++++++++++++++--------- test_app/package-lock.json | 4 ++-- 5 files changed, 42 insertions(+), 13 deletions(-) create mode 100755 npm/bin/capti.js rename npm/{bin => dist}/.gitignore (100%) diff --git a/npm/bin/capti.js b/npm/bin/capti.js new file mode 100755 index 0000000..3d5a0a0 --- /dev/null +++ b/npm/bin/capti.js @@ -0,0 +1,18 @@ +#!/usr/bin/env node + +import os from "os"; +import path from 'path'; +import { spawn } from "child_process"; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const DIST_DIR = path.resolve(__dirname, "..", "dist"); + +const extension = os.platform() === "win32" ? ".exe" : ""; +const binaryPath = path.resolve(DIST_DIR, `capti${extension}`); + +spawn(binaryPath, process.argv.slice(2), { + stdio: 'inherit', +}); diff --git a/npm/bin/.gitignore b/npm/dist/.gitignore similarity index 100% rename from npm/bin/.gitignore rename to npm/dist/.gitignore diff --git a/npm/package.json b/npm/package.json index 0c6c0f6..1f606d3 100644 --- a/npm/package.json +++ b/npm/package.json @@ -20,9 +20,9 @@ "directory": "npm" }, "type": "module", - "node": ">=14.0.0", + "node": ">=18.0.0", "bin": { - "capti": "./bin/capti" + "capti": "./bin/capti.js" }, "scripts": { "prepublishOnly": "cp ../README.md ./README.md", diff --git a/npm/scripts/download.js b/npm/scripts/download.js index 4ddba56..e6565a3 100755 --- a/npm/scripts/download.js +++ b/npm/scripts/download.js @@ -1,8 +1,16 @@ import os from "os"; import fs from "fs"; import fsPromises from "fs/promises"; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const DIST_PATH = path.resolve(__dirname, "..", "dist"); +const PACKAGE_JSON_PATH = path.resolve(__dirname, "..", "package.json"); +const LOGS_PATH = path.resolve(__dirname, "..", "logs"); -const BINARY_PATH = "./bin/capti"; const SUPPORTED_ARCHITECTURE = { linux: ["x64", "arm64"], darwin: ["x64", "arm64"], @@ -11,17 +19,18 @@ const SUPPORTED_ARCHITECTURE = { const createLogger = () => { const date = new Date().toISOString(); - const filename = `./logs/${date}.log`; + const filename = `${date}.log`; + const logPath = path.resolve(LOGS_PATH, filename); return (message) => { - fs.appendFileSync(filename, `${message}\n`); + fs.appendFileSync(logPath, `${message}\n`); }; }; const log = createLogger(); const loadPackageJson = () => { - const data = fs.readFileSync("package.json", { encoding: "utf8" }); + const data = fs.readFileSync(PACKAGE_JSON_PATH, { encoding: "utf8" }); return JSON.parse(data); }; @@ -42,7 +51,8 @@ const download = async () => { process.exit(1); } - const binary = `capti-${platform}-${arch}`; + const extension = platform === "win32" ? ".exe" : ""; + const binary = `capti-${platform}-${arch}${extension}`; log(`Downloading ${binary}`); @@ -65,14 +75,15 @@ const download = async () => { const buffer = Buffer.from(arrayBuffer); log("Buffer created, writing to file..."); - // fs.createWriteStream(BINARY_PATH).write(buffer); - await fsPromises.writeFile(BINARY_PATH, buffer); + const binaryPath = path.resolve(DIST_PATH, `capti${extension}`); + + await fsPromises.writeFile(binaryPath, buffer); log("Buffer written to file, setting permissions..."); - await fsPromises.chmod(BINARY_PATH, 0o755); + await fsPromises.chmod(binaryPath, 0o755); log("Permissions set."); - log(`Successfully downloaded to ${BINARY_PATH}`); + log(`Successfully downloaded to ${binaryPath}`); } catch (error) { log(`Failed to download: ${error.message}`); console.error("Failed to download/install:", error.message); diff --git a/test_app/package-lock.json b/test_app/package-lock.json index 9927849..d7a5525 100644 --- a/test_app/package-lock.json +++ b/test_app/package-lock.json @@ -31,12 +31,12 @@ }, "../npm": { "name": "capti", - "version": "0.1.0", + "version": "0.0.14", "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { - "capti": "bin/capti" + "capti": "bin/capti.js" } }, "node_modules/@aws-crypto/crc32": { From 9056fa36c4dd08c2e0f18f1a858485c92d9a3f8e Mon Sep 17 00:00:00 2001 From: Wvaviator Date: Sun, 11 Feb 2024 19:18:37 -0600 Subject: [PATCH 02/34] convert to commonjs for more support --- npm/bin/capti.js | 10 +++----- npm/package-lock.json | 57 +++++++++++++++++++++++++++++------------ npm/package.json | 10 +++++--- npm/scripts/download.js | 15 +++++------ 4 files changed, 55 insertions(+), 37 deletions(-) diff --git a/npm/bin/capti.js b/npm/bin/capti.js index 3d5a0a0..5d3561f 100755 --- a/npm/bin/capti.js +++ b/npm/bin/capti.js @@ -1,12 +1,8 @@ #!/usr/bin/env node -import os from "os"; -import path from 'path'; -import { spawn } from "child_process"; -import { fileURLToPath } from 'url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); +const os = require("os"); +const path = require("path"); +const { spawn } = require("child_process"); const DIST_DIR = path.resolve(__dirname, "..", "dist"); diff --git a/npm/package-lock.json b/npm/package-lock.json index 1014794..62e49e6 100644 --- a/npm/package-lock.json +++ b/npm/package-lock.json @@ -1,35 +1,58 @@ { "name": "capti", - "version": "0.0.10", + "version": "0.0.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "capti", - "version": "0.0.10", + "version": "0.0.14", "hasInstallScript": true, "license": "MIT", - "bin": { - "capti": "bin/capti" + "dependencies": { + "node-fetch": "^2.7.0" }, - "devDependencies": { - "@types/node": "^20.11.17" + "bin": { + "capti": "bin/capti.js" } }, - "node_modules/@types/node": { - "version": "20.11.17", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.17.tgz", - "integrity": "sha512-QmgQZGWu1Yw9TDyAP9ZzpFJKynYNeOvwMJmaxABfieQoVoiVOS6MN1WSpqpRcbeA5+RW82kraAVxCCJg+780Qw==", - "dev": true, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "dependencies": { - "undici-types": "~5.26.4" + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } } }, - "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } } } } diff --git a/npm/package.json b/npm/package.json index 1f606d3..395289f 100644 --- a/npm/package.json +++ b/npm/package.json @@ -19,15 +19,17 @@ "url": "git+https://github.com/WVAviator/capti.git", "directory": "npm" }, - "type": "module", - "node": ">=18.0.0", + "node": ">=10.0.0", "bin": { "capti": "./bin/capti.js" }, "scripts": { "prepublishOnly": "cp ../README.md ./README.md", - "preinstall": "node ./scripts/download.js" + "postinstall": "node ./scripts/download.js" }, "author": "wvaviator", - "license": "MIT" + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } } diff --git a/npm/scripts/download.js b/npm/scripts/download.js index e6565a3..965f3ab 100755 --- a/npm/scripts/download.js +++ b/npm/scripts/download.js @@ -1,11 +1,8 @@ -import os from "os"; -import fs from "fs"; -import fsPromises from "fs/promises"; -import path from 'path'; -import { fileURLToPath } from 'url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); +const os = require("os"); +const fs = require("fs"); +const fsPromises = require("fs/promises"); +const path = require("path"); +const fetch = require("node-fetch"); const DIST_PATH = path.resolve(__dirname, "..", "dist"); const PACKAGE_JSON_PATH = path.resolve(__dirname, "..", "package.json"); @@ -34,7 +31,7 @@ const loadPackageJson = () => { return JSON.parse(data); }; -const { version } = await loadPackageJson(); +const { version } = loadPackageJson(); log(`Loaded version ${version} from package.json`); const download = async () => { From dff84ed4c54690ef51d329549f6fae0d99d9b67e Mon Sep 17 00:00:00 2001 From: Wvaviator Date: Sun, 11 Feb 2024 19:28:38 -0600 Subject: [PATCH 03/34] correct cjs require statements --- npm/bin/capti.js | 6 +++--- npm/scripts/download.js | 12 +++++------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/npm/bin/capti.js b/npm/bin/capti.js index 5d3561f..f2b0b8f 100755 --- a/npm/bin/capti.js +++ b/npm/bin/capti.js @@ -1,8 +1,8 @@ #!/usr/bin/env node -const os = require("os"); -const path = require("path"); -const { spawn } = require("child_process"); +const os = require("node:os"); +const path = require("node:path"); +const { spawn } = require("node:child_process"); const DIST_DIR = path.resolve(__dirname, "..", "dist"); diff --git a/npm/scripts/download.js b/npm/scripts/download.js index 965f3ab..3fe7c05 100755 --- a/npm/scripts/download.js +++ b/npm/scripts/download.js @@ -1,7 +1,7 @@ -const os = require("os"); -const fs = require("fs"); -const fsPromises = require("fs/promises"); -const path = require("path"); +const os = require("node:os"); +const fs = require("node:fs"); +const fsPromises = require("node:fs/promises"); +const path = require("node:path"); const fetch = require("node-fetch"); const DIST_PATH = path.resolve(__dirname, "..", "dist"); @@ -40,9 +40,7 @@ const download = async () => { log(`Detected platform: ${platform}, arch: ${arch}`); - if ( - !SUPPORTED_ARCHITECTURE[platform].includes(arch) - ) { + if (!SUPPORTED_ARCHITECTURE[platform].includes(arch)) { log(`Unsupported platform and architecture: ${platform} ${arch}`); console.error("Unsupported platform and architecture:", platform, arch); process.exit(1); From d5205ef591986c809337e44889cc5c7f5e8d61b5 Mon Sep 17 00:00:00 2001 From: Wvaviator Date: Sun, 11 Feb 2024 19:31:40 -0600 Subject: [PATCH 04/34] switch back to esmodules --- npm/bin/capti.js | 6 +++--- npm/package.json | 10 ++++------ npm/scripts/download.js | 19 ++++++++++++------- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/npm/bin/capti.js b/npm/bin/capti.js index f2b0b8f..0c4c6ad 100755 --- a/npm/bin/capti.js +++ b/npm/bin/capti.js @@ -1,8 +1,8 @@ #!/usr/bin/env node -const os = require("node:os"); -const path = require("node:path"); -const { spawn } = require("node:child_process"); +import os from "os"; +import path from 'path'; +import { spawn } from "child_process"; const DIST_DIR = path.resolve(__dirname, "..", "dist"); diff --git a/npm/package.json b/npm/package.json index 395289f..1f606d3 100644 --- a/npm/package.json +++ b/npm/package.json @@ -19,17 +19,15 @@ "url": "git+https://github.com/WVAviator/capti.git", "directory": "npm" }, - "node": ">=10.0.0", + "type": "module", + "node": ">=18.0.0", "bin": { "capti": "./bin/capti.js" }, "scripts": { "prepublishOnly": "cp ../README.md ./README.md", - "postinstall": "node ./scripts/download.js" + "preinstall": "node ./scripts/download.js" }, "author": "wvaviator", - "license": "MIT", - "dependencies": { - "node-fetch": "^2.7.0" - } + "license": "MIT" } diff --git a/npm/scripts/download.js b/npm/scripts/download.js index 3fe7c05..e6565a3 100755 --- a/npm/scripts/download.js +++ b/npm/scripts/download.js @@ -1,8 +1,11 @@ -const os = require("node:os"); -const fs = require("node:fs"); -const fsPromises = require("node:fs/promises"); -const path = require("node:path"); -const fetch = require("node-fetch"); +import os from "os"; +import fs from "fs"; +import fsPromises from "fs/promises"; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); const DIST_PATH = path.resolve(__dirname, "..", "dist"); const PACKAGE_JSON_PATH = path.resolve(__dirname, "..", "package.json"); @@ -31,7 +34,7 @@ const loadPackageJson = () => { return JSON.parse(data); }; -const { version } = loadPackageJson(); +const { version } = await loadPackageJson(); log(`Loaded version ${version} from package.json`); const download = async () => { @@ -40,7 +43,9 @@ const download = async () => { log(`Detected platform: ${platform}, arch: ${arch}`); - if (!SUPPORTED_ARCHITECTURE[platform].includes(arch)) { + if ( + !SUPPORTED_ARCHITECTURE[platform].includes(arch) + ) { log(`Unsupported platform and architecture: ${platform} ${arch}`); console.error("Unsupported platform and architecture:", platform, arch); process.exit(1); From 7ae3580dd8b90a4927deaaecf091d9de9b3215ac Mon Sep 17 00:00:00 2001 From: Wvaviator Date: Sun, 11 Feb 2024 19:33:08 -0600 Subject: [PATCH 05/34] switch back to esmodules --- npm/bin/capti.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/npm/bin/capti.js b/npm/bin/capti.js index 0c4c6ad..a53c352 100755 --- a/npm/bin/capti.js +++ b/npm/bin/capti.js @@ -4,6 +4,9 @@ import os from "os"; import path from 'path'; import { spawn } from "child_process"; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + const DIST_DIR = path.resolve(__dirname, "..", "dist"); const extension = os.platform() === "win32" ? ".exe" : ""; From cf37d7031b16567e0ab6b02b891126de40e6be4f Mon Sep 17 00:00:00 2001 From: Wvaviator Date: Sun, 11 Feb 2024 19:34:08 -0600 Subject: [PATCH 06/34] correct dirname imports --- npm/bin/capti.js | 1 + 1 file changed, 1 insertion(+) diff --git a/npm/bin/capti.js b/npm/bin/capti.js index a53c352..3d5a0a0 100755 --- a/npm/bin/capti.js +++ b/npm/bin/capti.js @@ -3,6 +3,7 @@ import os from "os"; import path from 'path'; import { spawn } from "child_process"; +import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); From f822c03a4009309075df27575eea45b8a0c777c8 Mon Sep 17 00:00:00 2001 From: Wvaviator Date: Tue, 13 Feb 2024 16:32:11 -0600 Subject: [PATCH 07/34] update readme --- README.md | 1 + npm/README.md | 18 +++++++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5156a1d..9529861 100644 --- a/README.md +++ b/README.md @@ -328,6 +328,7 @@ Capti is under active development and is not production ready. If you want to co ### Stretch Features 1. Support for other frameworks? 2. Coverage reports? +3. Plugin API for custom matchers? 3. Whatever you suggest or require for your project. ### Contributing diff --git a/npm/README.md b/npm/README.md index 2deb754..9529861 100644 --- a/npm/README.md +++ b/npm/README.md @@ -2,6 +2,20 @@ Capti is a lightweight end-to-end testing framework for REST APIs. Define your requests and expected response values in an intuitive YAML format, and streamline your endpoint testing. +```yaml + - test: Get recipe + description: "Should be able to get recipe information" + request: + method: GET + url: ${BASE_URL}/recipes/${RECIPE_ID} + expect: + status: 2xx + body: + id: ${RECIPE_ID} + name: Guacamole + ingredients: $exists +``` + ## Installation Capti is currently available as an NPM package, however Capti is framework-agnostic and can work with any REST APIs. The raw binaries are available on the [GitHub repo](https://github.com/WVAviator/capti/releases) if you prefer not to use NPM. @@ -26,12 +40,9 @@ Then edit your package.json scripts by adding a `test:capti` script: ```json { - ... "scripts": { - ... "test:capti": "capti --path './tests'" } - ... } ``` @@ -317,6 +328,7 @@ Capti is under active development and is not production ready. If you want to co ### Stretch Features 1. Support for other frameworks? 2. Coverage reports? +3. Plugin API for custom matchers? 3. Whatever you suggest or require for your project. ### Contributing From 4800f5603e73b6b87e25ded8fb0c77c9c4688990 Mon Sep 17 00:00:00 2001 From: Wvaviator Date: Tue, 13 Feb 2024 20:47:09 -0600 Subject: [PATCH 08/34] add length matcher --- README.md | 13 +++++++++++++ npm/README.md | 13 +++++++++++++ src/matcher/matcher.rs | 28 ++++++++++++++++++++++++++++ test_app/tests/recipe_create.yaml | 9 +++++++++ 4 files changed, 63 insertions(+) diff --git a/README.md b/README.md index 9529861..f6bd392 100644 --- a/README.md +++ b/README.md @@ -288,6 +288,19 @@ Suites always run concurrently. You should not design your API tests to where an Note: Regex matchers only work on string values and will return false otherwise. +- `$length` - Match the length of an array, object (number of keys), or string. The `$length` matcher should be followed by a number that represents the expected length of the array/object/string. + +```yaml + - test: User only has one recipe + description: User should only show one recipe in their list + request: + method: GET + url: ${BASE_URL}/recipes + expect: + status: 2xx + body: $length 1 +``` + - `$includes` - This matcher is useful for ensuring values exist in arrays. Any value that follows this keyword is incorporated as a matcher itself and follows the same matching rules. You can match strings, booleans, and even objects (however, you will have to define them as JSON strings). Example: ```yaml diff --git a/npm/README.md b/npm/README.md index 9529861..f6bd392 100644 --- a/npm/README.md +++ b/npm/README.md @@ -288,6 +288,19 @@ Suites always run concurrently. You should not design your API tests to where an Note: Regex matchers only work on string values and will return false otherwise. +- `$length` - Match the length of an array, object (number of keys), or string. The `$length` matcher should be followed by a number that represents the expected length of the array/object/string. + +```yaml + - test: User only has one recipe + description: User should only show one recipe in their list + request: + method: GET + url: ${BASE_URL}/recipes + expect: + status: 2xx + body: $length 1 +``` + - `$includes` - This matcher is useful for ensuring values exist in arrays. Any value that follows this keyword is incorporated as a matcher itself and follows the same matching rules. You can match strings, booleans, and even objects (however, you will have to define them as JSON strings). Example: ```yaml diff --git a/src/matcher/matcher.rs b/src/matcher/matcher.rs index 6c48b91..64d3ee4 100644 --- a/src/matcher/matcher.rs +++ b/src/matcher/matcher.rs @@ -1,5 +1,8 @@ +use colored::Colorize; use regex::Regex; +use crate::progress_println; + use super::{MatchCmp, MatchResult}; pub enum Matcher { @@ -9,6 +12,7 @@ pub enum Matcher { Includes(serde_json::Value), Empty, Absent, + Length(usize), } impl Matcher { @@ -39,6 +43,12 @@ impl Matcher { serde_json::Value::Null => true, _ => false, }, + Matcher::Length(length) => match value { + serde_json::Value::Array(arr) => arr.len().eq(length), + serde_json::Value::Object(obj) => obj.len().eq(length), + serde_json::Value::String(s) => s.len().eq(length), + _ => false, + }, } } } @@ -49,6 +59,14 @@ impl From<&str> for Matcher { "$exists" => Matcher::Exists, "$empty" => Matcher::Empty, "$absent" => Matcher::Absent, + s if s.starts_with("$length") => { + let length = value[8..].parse::().unwrap_or_else(|_| { + progress_println!("{}: Invalid length matcher {}.", "Warning".yellow(), value); + 0 + }); + + Matcher::Length(length) + } s if s.starts_with("$regex") => match extract_regex(s) { Some(regex) => Matcher::Regex(regex), None => Matcher::Exact(s.to_string()), @@ -145,4 +163,14 @@ mod test { let hay = "Hello! How are you?"; assert!(re.is_match(hay)); } + + #[test] + fn test_length() { + let length_str = "$length 3"; + let match_arr = serde_json::from_str("[1, 2, 3]").unwrap(); + let matcher = Matcher::from(length_str); + let matches = matcher.matches_value(&match_arr); + + assert!(matches); + } } diff --git a/test_app/tests/recipe_create.yaml b/test_app/tests/recipe_create.yaml index 36f1082..4beefb3 100644 --- a/test_app/tests/recipe_create.yaml +++ b/test_app/tests/recipe_create.yaml @@ -94,6 +94,15 @@ tests: status: 2xx body: '$includes { "id": "${RECIPE_ID}" }' + - test: User only has one recipe + description: User should only show one recipe in their list + request: + method: GET + url: ${BASE_URL}/recipes + expect: + status: 2xx + body: $length 1 + - test: Delete recipe description: "Deletes the new recipe" request: From f7237cf417ad08f67b4e0a2a30afcccc321fd589 Mon Sep 17 00:00:00 2001 From: Wvaviator Date: Thu, 15 Feb 2024 17:18:46 -0600 Subject: [PATCH 09/34] create basic structure for mvalue --- src/lib.rs | 1 + src/m_value/m_value.rs | 16 ++++++++++++++++ src/m_value/match_processor.rs | 5 +++++ src/m_value/matcher_definition.rs | 6 ++++++ src/m_value/matcher_map.rs | 5 +++++ src/m_value/mod.rs | 4 ++++ 6 files changed, 37 insertions(+) create mode 100644 src/m_value/m_value.rs create mode 100644 src/m_value/match_processor.rs create mode 100644 src/m_value/matcher_definition.rs create mode 100644 src/m_value/matcher_map.rs create mode 100644 src/m_value/mod.rs diff --git a/src/lib.rs b/src/lib.rs index a10ac49..a231bcf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ pub mod args; pub mod client; pub mod errors; pub mod formatting; +pub mod m_value; pub mod matcher; pub mod progress; pub mod runner; diff --git a/src/m_value/m_value.rs b/src/m_value/m_value.rs new file mode 100644 index 0000000..847e05d --- /dev/null +++ b/src/m_value/m_value.rs @@ -0,0 +1,16 @@ +use std::collections::HashMap; + +use serde_yaml::Number; + +use super::matcher_definition::MatcherDefintion; + +pub enum MValue { + Null, + Bool(bool), + Number(Number), + String(String), + Array(Vec), + Object(HashMap), + Matcher(Box), + Variable(String), +} diff --git a/src/m_value/match_processor.rs b/src/m_value/match_processor.rs new file mode 100644 index 0000000..044de0b --- /dev/null +++ b/src/m_value/match_processor.rs @@ -0,0 +1,5 @@ +use super::m_value::MValue; + +pub trait MatchProcessor { + fn is_match(&self, args: MValue, value: MValue) -> bool; +} diff --git a/src/m_value/matcher_definition.rs b/src/m_value/matcher_definition.rs new file mode 100644 index 0000000..d34cd12 --- /dev/null +++ b/src/m_value/matcher_definition.rs @@ -0,0 +1,6 @@ +use super::m_value::MValue; + +pub struct MatcherDefintion { + match_key: String, + args: MValue, +} diff --git a/src/m_value/matcher_map.rs b/src/m_value/matcher_map.rs new file mode 100644 index 0000000..d1f86b1 --- /dev/null +++ b/src/m_value/matcher_map.rs @@ -0,0 +1,5 @@ +use std::collections::HashMap; + +use super::match_processor::MatchProcessor; + +pub struct MatcherMap(HashMap>); diff --git a/src/m_value/mod.rs b/src/m_value/mod.rs new file mode 100644 index 0000000..7c53387 --- /dev/null +++ b/src/m_value/mod.rs @@ -0,0 +1,4 @@ +pub mod m_value; +pub mod match_processor; +pub mod matcher_definition; +pub mod matcher_map; From b995b131df05e14418921be562c254bce07bb53d Mon Sep 17 00:00:00 2001 From: Wvaviator Date: Thu, 15 Feb 2024 18:33:17 -0600 Subject: [PATCH 10/34] implement matcher trait and matchermap --- src/m_value/m_value.rs | 32 +++++++++++++++++++- src/m_value/match_processor.rs | 5 ++-- src/m_value/matcher_definition.rs | 32 +++++++++++++++++++- src/m_value/matcher_map.rs | 48 ++++++++++++++++++++++++++++-- src/m_value/mod.rs | 1 + src/m_value/std_matchers/exists.rs | 23 ++++++++++++++ src/m_value/std_matchers/mod.rs | 1 + 7 files changed, 136 insertions(+), 6 deletions(-) create mode 100644 src/m_value/std_matchers/exists.rs create mode 100644 src/m_value/std_matchers/mod.rs diff --git a/src/m_value/m_value.rs b/src/m_value/m_value.rs index 847e05d..81ea115 100644 --- a/src/m_value/m_value.rs +++ b/src/m_value/m_value.rs @@ -1,5 +1,9 @@ -use std::collections::HashMap; +use std::{collections::HashMap, fmt}; +use serde::{ + de::{self, Visitor}, + Deserialize, Deserializer, +}; use serde_yaml::Number; use super::matcher_definition::MatcherDefintion; @@ -14,3 +18,29 @@ pub enum MValue { Matcher(Box), Variable(String), } + +impl<'de> Deserialize<'de> for MValue { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_any(MValueVisitor) + } +} + +struct MValueVisitor; + +impl<'de> Visitor<'de> for MValueVisitor { + type Value = MValue; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a valid matcher or yaml value") + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + todo!("Check MatcherMap for matching string"); + } +} diff --git a/src/m_value/match_processor.rs b/src/m_value/match_processor.rs index 044de0b..ef5617b 100644 --- a/src/m_value/match_processor.rs +++ b/src/m_value/match_processor.rs @@ -1,5 +1,6 @@ use super::m_value::MValue; -pub trait MatchProcessor { - fn is_match(&self, args: MValue, value: MValue) -> bool; +pub trait MatchProcessor: Send + Sync { + fn key(&self) -> String; + fn is_match(&self, args: &MValue, value: &MValue) -> bool; } diff --git a/src/m_value/matcher_definition.rs b/src/m_value/matcher_definition.rs index d34cd12..9f736a4 100644 --- a/src/m_value/matcher_definition.rs +++ b/src/m_value/matcher_definition.rs @@ -1,6 +1,36 @@ -use super::m_value::MValue; +use super::{m_value::MValue, match_processor::MatchProcessor, matcher_map::MatcherMap}; pub struct MatcherDefintion { match_key: String, args: MValue, } + +impl MatcherDefintion { + fn is_match(&self, value: &MValue) -> bool { + if let Some(matcher) = MatcherMap::get_matcher(&self.match_key) { + return matcher.is_match(&self.args, value); + } + + false + } +} + +impl TryFrom<&str> for MatcherDefintion { + type Error = (); + + fn try_from(value: &str) -> Result { + let mut parts = value.split(" "); + if let Some(key_candidate) = parts.next() { + if let Some(_) = MatcherMap::get_matcher(key_candidate) { + let args = parts.collect::(); + let args = serde_yaml::from_str::(&args).unwrap_or(MValue::String(args)); + return Ok(MatcherDefintion { + match_key: key_candidate.to_string(), + args, + }); + } + } + + return Err(()); + } +} diff --git a/src/m_value/matcher_map.rs b/src/m_value/matcher_map.rs index d1f86b1..60df5a2 100644 --- a/src/m_value/matcher_map.rs +++ b/src/m_value/matcher_map.rs @@ -1,5 +1,49 @@ -use std::collections::HashMap; +use std::{collections::HashMap, ops::Deref}; -use super::match_processor::MatchProcessor; +use lazy_static::lazy_static; + +use super::{match_processor::MatchProcessor, std_matchers::exists::Exists}; + +lazy_static! { + static ref MATCHER_MAP: MatcherMap = MatcherMap::initialize(); +} pub struct MatcherMap(HashMap>); + +impl MatcherMap { + pub fn initialize() -> Self { + let mut map = MatcherMap(HashMap::new()); + + map.insert_mp(Exists::new()); + + map + } + + fn insert_mp(&mut self, processor: Box) { + self.0.insert(processor.key(), processor); + } + + pub fn get_matcher(key: &str) -> Option<&Box> { + MATCHER_MAP.get(key) + } +} + +impl Deref for MatcherMap { + type Target = HashMap>; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[cfg(test)] +mod test { + use crate::m_value::m_value::MValue; + + use super::*; + + #[test] + fn can_get_exists_matcher() { + let matcher = MatcherMap::get_matcher("$exists").unwrap(); + assert!(matcher.is_match(&MValue::Null, &MValue::Bool(false))); + } +} diff --git a/src/m_value/mod.rs b/src/m_value/mod.rs index 7c53387..1b6289d 100644 --- a/src/m_value/mod.rs +++ b/src/m_value/mod.rs @@ -2,3 +2,4 @@ pub mod m_value; pub mod match_processor; pub mod matcher_definition; pub mod matcher_map; +mod std_matchers; diff --git a/src/m_value/std_matchers/exists.rs b/src/m_value/std_matchers/exists.rs new file mode 100644 index 0000000..9552bec --- /dev/null +++ b/src/m_value/std_matchers/exists.rs @@ -0,0 +1,23 @@ +use crate::m_value::{m_value::MValue, match_processor::MatchProcessor}; + +#[derive(Default)] +pub struct Exists; + +impl Exists { + pub fn new() -> Box { + Box::new(Exists) + } +} + +impl MatchProcessor for Exists { + fn key(&self) -> String { + String::from("$exists") + } + + fn is_match(&self, _args: &MValue, value: &MValue) -> bool { + match value { + MValue::Null => false, + _ => true, + } + } +} diff --git a/src/m_value/std_matchers/mod.rs b/src/m_value/std_matchers/mod.rs new file mode 100644 index 0000000..fe8bcec --- /dev/null +++ b/src/m_value/std_matchers/mod.rs @@ -0,0 +1 @@ +pub mod exists; From 32c65cd2f4df482d477192e92bc1e0a5556eeb33 Mon Sep 17 00:00:00 2001 From: Wvaviator Date: Fri, 16 Feb 2024 08:35:48 -0600 Subject: [PATCH 11/34] build custom yaml deserialization --- Cargo.lock | 5 +- Cargo.toml | 1 + src/m_value/m_map.rs | 153 ++++++++++++++++++++++ src/m_value/m_value.rs | 205 +++++++++++++++++++++++++++--- src/m_value/matcher_definition.rs | 3 +- src/m_value/mod.rs | 3 +- 6 files changed, 350 insertions(+), 20 deletions(-) create mode 100644 src/m_value/m_map.rs diff --git a/Cargo.lock b/Cargo.lock index d32f89d..25e9844 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -132,6 +132,7 @@ dependencies = [ "clap", "colored", "futures", + "indexmap", "indicatif", "lazy_static", "openssl", @@ -574,9 +575,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.2.2" +version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824b2ae422412366ba479e8111fd301f7b5faece8149317bb81925979a53f520" +checksum = "233cf39063f058ea2caae4091bf4a3ef70a653afbc026f5c4a4135d114e3c177" dependencies = [ "equivalent", "hashbrown", diff --git a/Cargo.toml b/Cargo.toml index a2cf020..0af8b81 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ thiserror = "1.0.56" tokio = { version = "1.35.1", features = ["rt-multi-thread", "macros", "sync"] } walkdir = "2.4.0" openssl = { version = "0.10", optional = true } +indexmap = "2.2.3" [target.'cfg(all(target_arch = "aarch64", target_os = "linux"))'.dependencies] openssl = { version = "0.10", features = ["vendored"] } diff --git a/src/m_value/m_map.rs b/src/m_value/m_map.rs new file mode 100644 index 0000000..a481f6f --- /dev/null +++ b/src/m_value/m_map.rs @@ -0,0 +1,153 @@ +use std::{ + collections::hash_map::DefaultHasher, + fmt::{self, Display}, + hash::{Hash, Hasher}, + ops::{Deref, DerefMut}, +}; + +use indexmap::IndexMap; +use serde::{Deserialize, Deserializer}; + +use super::m_value::MValue; + +#[derive(Debug, PartialEq, Clone)] +pub struct Mapping { + map: IndexMap, +} + +impl Mapping { + pub fn new() -> Self { + Mapping { + map: IndexMap::new(), + } + } + + fn entry(&mut self, key: MValue) -> Entry { + match self.map.entry(key) { + indexmap::map::Entry::Occupied(occupied) => Entry::Occupied(OccupiedEntry { occupied }), + indexmap::map::Entry::Vacant(vacant) => Entry::Vacant(VacantEntry { vacant }), + } + } +} + +impl Hash for Mapping { + fn hash(&self, state: &mut H) { + let mut xor = 0; + for (k, v) in &self.map { + let mut hasher = DefaultHasher::new(); + k.hash(&mut hasher); + v.hash(&mut hasher); + xor ^= hasher.finish(); + } + xor.hash(state); + } +} + +impl Deref for Mapping { + type Target = IndexMap; + fn deref(&self) -> &Self::Target { + &self.map + } +} + +impl DerefMut for Mapping { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.map + } +} + +impl<'de> Deserialize<'de> for Mapping { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct Visitor; + + impl<'de> serde::de::Visitor<'de> for Visitor { + type Value = Mapping; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a YAML mapping") + } + + #[inline] + fn visit_unit(self) -> Result + where + E: serde::de::Error, + { + Ok(Mapping::new()) + } + + #[inline] + fn visit_map(self, mut data: A) -> Result + where + A: serde::de::MapAccess<'de>, + { + let mut mapping = Mapping::new(); + + while let Some(key) = data.next_key()? { + match mapping.entry(key) { + Entry::Occupied(entry) => { + return Err(serde::de::Error::custom(DuplicateKeyError { entry })); + } + Entry::Vacant(entry) => { + let value = data.next_value()?; + entry.insert(value); + } + } + } + + Ok(mapping) + } + } + + deserializer.deserialize_map(Visitor) + } +} + +struct DuplicateKeyError<'a> { + entry: OccupiedEntry<'a>, +} + +impl<'a> Display for DuplicateKeyError<'a> { + fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("duplicate entry ")?; + match self.entry.key() { + MValue::Null => formatter.write_str("with null key"), + MValue::Bool(boolean) => write!(formatter, "with key `{}`", boolean), + MValue::Number(number) => write!(formatter, "with key {}", number), + MValue::String(string) => { + write!(formatter, "with key {:?}", string) + } + MValue::Matcher(matcher) => { + write!(formatter, "with matched key {:?}", matcher) + } + MValue::Sequence(_) | MValue::Mapping(_) => formatter.write_str("in YAML map"), + } + } +} + +enum Entry<'a> { + Occupied(OccupiedEntry<'a>), + Vacant(VacantEntry<'a>), +} + +struct OccupiedEntry<'a> { + occupied: indexmap::map::OccupiedEntry<'a, MValue, MValue>, +} + +impl<'a> OccupiedEntry<'a> { + fn key(&self) -> &MValue { + self.occupied.key() + } +} + +struct VacantEntry<'a> { + vacant: indexmap::map::VacantEntry<'a, MValue, MValue>, +} + +impl<'a> VacantEntry<'a> { + fn insert(self, value: MValue) { + self.vacant.insert(value); + } +} diff --git a/src/m_value/m_value.rs b/src/m_value/m_value.rs index 81ea115..424bf41 100644 --- a/src/m_value/m_value.rs +++ b/src/m_value/m_value.rs @@ -1,46 +1,219 @@ -use std::{collections::HashMap, fmt}; +use std::fmt; use serde::{ - de::{self, Visitor}, + de::{self, MapAccess, SeqAccess, Visitor}, Deserialize, Deserializer, }; use serde_yaml::Number; -use super::matcher_definition::MatcherDefintion; +use super::{m_map::Mapping, matcher_definition::MatcherDefintion}; +#[derive(Debug, Hash, PartialEq, Clone)] pub enum MValue { Null, Bool(bool), Number(Number), String(String), - Array(Vec), - Object(HashMap), + Sequence(Sequence), + Mapping(Mapping), Matcher(Box), - Variable(String), } +impl Default for MValue { + fn default() -> Self { + MValue::Null + } +} + +impl Eq for MValue {} + +pub type Sequence = Vec; + impl<'de> Deserialize<'de> for MValue { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { + struct MValueVisitor; + + impl<'de> Visitor<'de> for MValueVisitor { + type Value = MValue; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a valid matcher or yaml value") + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + match MatcherDefintion::try_from(value) { + Ok(matcher) => Ok(MValue::Matcher(Box::new(matcher))), + Err(_) => Ok(MValue::String(String::from(value))), + } + } + + fn visit_string(self, value: String) -> Result + where + E: de::Error, + { + match MatcherDefintion::try_from(value.as_str()) { + Ok(matcher) => Ok(MValue::Matcher(Box::new(matcher))), + Err(_) => Ok(MValue::String(value)), + } + } + + fn visit_bool(self, value: bool) -> Result + where + E: de::Error, + { + Ok(MValue::Bool(value)) + } + + fn visit_i64(self, value: i64) -> Result + where + E: de::Error, + { + Ok(MValue::Number(value.into())) + } + + fn visit_u64(self, value: u64) -> Result + where + E: de::Error, + { + Ok(MValue::Number(value.into())) + } + + fn visit_f64(self, value: f64) -> Result + where + E: de::Error, + { + Ok(MValue::Number(value.into())) + } + + fn visit_unit(self) -> Result + where + E: de::Error, + { + Ok(MValue::Null) + } + + fn visit_none(self) -> Result + where + E: de::Error, + { + Ok(MValue::Null) + } + + fn visit_some(self, deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Deserialize::deserialize(deserializer) + } + + fn visit_seq(self, data: A) -> Result + where + A: SeqAccess<'de>, + { + let de = serde::de::value::SeqAccessDeserializer::new(data); + let sequence = Sequence::deserialize(de)?; + Ok(MValue::Sequence(sequence)) + } + + fn visit_map(self, data: A) -> Result + where + A: MapAccess<'de>, + { + let de = serde::de::value::MapAccessDeserializer::new(data); + let mapping = Mapping::deserialize(de)?; + Ok(MValue::Mapping(mapping)) + } + } + deserializer.deserialize_any(MValueVisitor) } } -struct MValueVisitor; +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn deserializes_from_standard_yaml() { + let yaml = r#" + hello: + - null + - true + - 1 + world: + - 1.0 + - "string" + - false + - test: "true" + abc: def + "#; + + let mut mapping = Mapping::new(); + mapping.insert( + MValue::String("hello".to_string()), + MValue::Sequence(vec![ + MValue::Null, + MValue::Bool(true), + MValue::Number(1.into()), + ]), + ); + let mut nested_mapping = Mapping::new(); + nested_mapping.insert( + MValue::String("test".to_string()), + MValue::String("true".to_string()), + ); + nested_mapping.insert( + MValue::String("abc".to_string()), + MValue::String("def".to_string()), + ); + mapping.insert( + MValue::String("world".to_string()), + MValue::Sequence(vec![ + MValue::Number(1.0.into()), + MValue::String("string".to_string()), + MValue::Bool(false), + MValue::Mapping(nested_mapping), + ]), + ); + + let expected = MValue::Mapping(mapping); -impl<'de> Visitor<'de> for MValueVisitor { - type Value = MValue; + let value = serde_yaml::from_str::(yaml).unwrap(); - fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - formatter.write_str("a valid matcher or yaml value") + assert_eq!(value, expected); } - fn visit_str(self, value: &str) -> Result - where - E: de::Error, - { - todo!("Check MatcherMap for matching string"); + #[test] + fn deserializes_yaml_with_matchers() { + let yaml = r#" + hello: + - $exists + - true + - 1 + "#; + + let matcher = MatcherDefintion::try_from("$exists").unwrap(); + + let mut mapping = Mapping::new(); + mapping.insert( + MValue::String("hello".to_string()), + MValue::Sequence(vec![ + MValue::Matcher(Box::new(matcher)), + MValue::Bool(true), + MValue::Number(1.into()), + ]), + ); + + let expected = MValue::Mapping(mapping); + + let value = serde_yaml::from_str::(yaml).unwrap(); + + assert_eq!(expected, value); } } diff --git a/src/m_value/matcher_definition.rs b/src/m_value/matcher_definition.rs index 9f736a4..e476e2d 100644 --- a/src/m_value/matcher_definition.rs +++ b/src/m_value/matcher_definition.rs @@ -1,5 +1,6 @@ -use super::{m_value::MValue, match_processor::MatchProcessor, matcher_map::MatcherMap}; +use super::{m_value::MValue, matcher_map::MatcherMap}; +#[derive(Debug, Clone, PartialEq, Hash)] pub struct MatcherDefintion { match_key: String, args: MValue, diff --git a/src/m_value/mod.rs b/src/m_value/mod.rs index 1b6289d..410c9ed 100644 --- a/src/m_value/mod.rs +++ b/src/m_value/mod.rs @@ -1,5 +1,6 @@ +pub mod m_map; pub mod m_value; pub mod match_processor; pub mod matcher_definition; pub mod matcher_map; -mod std_matchers; +pub mod std_matchers; From 3fdc669307e291da65088e77b76bc627ca6493c6 Mon Sep 17 00:00:00 2001 From: Wvaviator Date: Fri, 16 Feb 2024 12:43:20 -0600 Subject: [PATCH 12/34] add std matchers absent and regex --- src/m_value/m_map.rs | 24 ++++- src/m_value/m_value.rs | 146 ++++++++++++++++++++++++++++- src/m_value/matcher_definition.rs | 15 ++- src/m_value/matcher_map.rs | 4 +- src/m_value/std_matchers/absent.rs | 23 +++++ src/m_value/std_matchers/mod.rs | 6 ++ src/m_value/std_matchers/regex.rs | 82 ++++++++++++++++ 7 files changed, 294 insertions(+), 6 deletions(-) create mode 100644 src/m_value/std_matchers/absent.rs create mode 100644 src/m_value/std_matchers/regex.rs diff --git a/src/m_value/m_map.rs b/src/m_value/m_map.rs index a481f6f..9ac3795 100644 --- a/src/m_value/m_map.rs +++ b/src/m_value/m_map.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Deserializer}; use super::m_value::MValue; -#[derive(Debug, PartialEq, Clone)] +#[derive(Debug, Clone)] pub struct Mapping { map: IndexMap, } @@ -30,6 +30,18 @@ impl Mapping { } } +impl PartialEq for Mapping { + fn eq(&self, other: &Self) -> bool { + for (k, v) in &self.map { + if v != other.get(k).unwrap_or(&MValue::Null) { + return false; + } + } + + true + } +} + impl Hash for Mapping { fn hash(&self, state: &mut H) { let mut xor = 0; @@ -43,6 +55,16 @@ impl Hash for Mapping { } } +impl From> for Mapping { + fn from(vec: Vec<(MValue, MValue)>) -> Self { + let mut map = IndexMap::new(); + for (k, v) in vec { + map.insert(k, v); + } + Mapping { map } + } +} + impl Deref for Mapping { type Target = IndexMap; fn deref(&self) -> &Self::Target { diff --git a/src/m_value/m_value.rs b/src/m_value/m_value.rs index 424bf41..b7bc702 100644 --- a/src/m_value/m_value.rs +++ b/src/m_value/m_value.rs @@ -6,9 +6,11 @@ use serde::{ }; use serde_yaml::Number; +use crate::variables::SuiteVariables; + use super::{m_map::Mapping, matcher_definition::MatcherDefintion}; -#[derive(Debug, Hash, PartialEq, Clone)] +#[derive(Debug, Hash, Clone)] pub enum MValue { Null, Bool(bool), @@ -135,6 +137,81 @@ impl<'de> Deserialize<'de> for MValue { } } +impl PartialEq for MValue { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Bool(l0), Self::Bool(r0)) => l0 == r0, + (Self::Number(l0), Self::Number(r0)) => l0 == r0, + (Self::String(l0), Self::String(r0)) => l0 == r0, + (Self::Sequence(l0), Self::Sequence(r0)) => l0 == r0, + (Self::Mapping(l0), Self::Mapping(r0)) => l0 == r0, + (Self::Matcher(l0), other) => l0.is_match(&other), + _ => core::mem::discriminant(self) == core::mem::discriminant(other), + } + } +} + +impl SuiteVariables for MValue { + fn populate_variables( + &mut self, + variables: &mut crate::variables::variable_map::VariableMap, + ) -> Result<(), crate::errors::CaptiError> { + match self { + MValue::String(s) => { + *s = variables.replace_variables(&s)?; + } + MValue::Sequence(seq) => { + for value in seq { + value.populate_variables(variables)?; + } + } + MValue::Mapping(mapping) => { + for value in mapping.values_mut() { + value.populate_variables(variables)?; + } + } + _ => {} + } + + Ok(()) + } +} + +impl fmt::Display for MValue { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + MValue::Null => write!(f, "null")?, + MValue::Bool(b) => write!(f, "{}", b)?, + MValue::Number(n) => write!(f, "{}", n)?, + MValue::String(s) => write!(f, "\"{}\"", s)?, + MValue::Sequence(arr) => { + write!(f, "[")?; + for (i, value) in arr.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + write!(f, "{}", value)?; + } + write!(f, "]")?; + } + MValue::Mapping(m) => { + writeln!(f, "{{")?; + for (i, (key, value)) in m.iter().enumerate() { + if i > 0 { + writeln!(f, ", ")?; + } + write!(f, "{}: {}", key, value)?; + } + writeln!(f, " ")?; + write!(f, "}}")?; + } + MValue::Matcher(m) => write!(f, "{}", m)?, + } + + Ok(()) + } +} + #[cfg(test)] mod test { use super::*; @@ -216,4 +293,71 @@ mod test { assert_eq!(expected, value); } + + #[test] + fn matcher_equality_after_deserialize() { + let yaml1 = r#" + hello: $exists + "#; + + let yaml2 = r#" + hello: something + "#; + + let yaml3 = r#" + world: not hello + "#; + + let yaml1 = serde_yaml::from_str::(yaml1).unwrap(); + let yaml2 = serde_yaml::from_str::(yaml2).unwrap(); + let yaml3 = serde_yaml::from_str::(yaml3).unwrap(); + + assert_eq!(yaml1, yaml2); + assert_ne!(yaml1, yaml3); + } + + #[test] + fn populates_variables_in_string() { + let mut variables = crate::variables::variable_map::VariableMap::new(); + variables.insert("HELLO", "hi"); + let mut value = MValue::String("Say ${HELLO}!".to_string()); + value.populate_variables(&mut variables).unwrap(); + assert_eq!(value, MValue::String("Say hi!".to_string())); + } + + #[test] + fn populates_variables_nested() { + let mut variables = crate::variables::variable_map::VariableMap::new(); + variables.insert("HELLO", "hi"); + let mut value = MValue::Mapping(Mapping::from(vec![ + ( + MValue::String("hello".to_string()), + MValue::String("Say ${HELLO}!".to_string()), + ), + ( + MValue::String("world".to_string()), + MValue::Sequence(vec![ + MValue::String("Say ${HELLO}!".to_string()), + MValue::String("Say ${HELLO}!".to_string()), + ]), + ), + ])); + value.populate_variables(&mut variables).unwrap(); + assert_eq!( + value, + MValue::Mapping(Mapping::from(vec![ + ( + MValue::String("hello".to_string()), + MValue::String("Say hi!".to_string()), + ), + ( + MValue::String("world".to_string()), + MValue::Sequence(vec![ + MValue::String("Say hi!".to_string()), + MValue::String("Say hi!".to_string()), + ]), + ), + ])) + ); + } } diff --git a/src/m_value/matcher_definition.rs b/src/m_value/matcher_definition.rs index e476e2d..1f0e29e 100644 --- a/src/m_value/matcher_definition.rs +++ b/src/m_value/matcher_definition.rs @@ -1,3 +1,5 @@ +use std::fmt; + use super::{m_value::MValue, matcher_map::MatcherMap}; #[derive(Debug, Clone, PartialEq, Hash)] @@ -7,7 +9,7 @@ pub struct MatcherDefintion { } impl MatcherDefintion { - fn is_match(&self, value: &MValue) -> bool { + pub fn is_match(&self, value: &MValue) -> bool { if let Some(matcher) = MatcherMap::get_matcher(&self.match_key) { return matcher.is_match(&self.args, value); } @@ -16,6 +18,13 @@ impl MatcherDefintion { } } +impl fmt::Display for MatcherDefintion { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} {}", self.match_key, self.args)?; + Ok(()) + } +} + impl TryFrom<&str> for MatcherDefintion { type Error = (); @@ -23,8 +32,8 @@ impl TryFrom<&str> for MatcherDefintion { let mut parts = value.split(" "); if let Some(key_candidate) = parts.next() { if let Some(_) = MatcherMap::get_matcher(key_candidate) { - let args = parts.collect::(); - let args = serde_yaml::from_str::(&args).unwrap_or(MValue::String(args)); + let args = parts.map(|s| s.into()).collect::>().join(" "); + let args = serde_json::from_str::(&args).unwrap_or(MValue::Null); return Ok(MatcherDefintion { match_key: key_candidate.to_string(), args, diff --git a/src/m_value/matcher_map.rs b/src/m_value/matcher_map.rs index 60df5a2..9f1dff5 100644 --- a/src/m_value/matcher_map.rs +++ b/src/m_value/matcher_map.rs @@ -2,7 +2,7 @@ use std::{collections::HashMap, ops::Deref}; use lazy_static::lazy_static; -use super::{match_processor::MatchProcessor, std_matchers::exists::Exists}; +use super::{match_processor::MatchProcessor, std_matchers::*}; lazy_static! { static ref MATCHER_MAP: MatcherMap = MatcherMap::initialize(); @@ -15,6 +15,8 @@ impl MatcherMap { let mut map = MatcherMap(HashMap::new()); map.insert_mp(Exists::new()); + map.insert_mp(Regex::new()); + map.insert_mp(Absent::new()); map } diff --git a/src/m_value/std_matchers/absent.rs b/src/m_value/std_matchers/absent.rs new file mode 100644 index 0000000..2add7fe --- /dev/null +++ b/src/m_value/std_matchers/absent.rs @@ -0,0 +1,23 @@ +use crate::m_value::{m_value::MValue, match_processor::MatchProcessor}; + +#[derive(Default)] +pub struct Absent; + +impl Absent { + pub fn new() -> Box { + Box::new(Absent) + } +} + +impl MatchProcessor for Absent { + fn key(&self) -> String { + String::from("$absent") + } + + fn is_match(&self, _args: &MValue, value: &MValue) -> bool { + match value { + MValue::Null => true, + _ => false, + } + } +} diff --git a/src/m_value/std_matchers/mod.rs b/src/m_value/std_matchers/mod.rs index fe8bcec..cfc629d 100644 --- a/src/m_value/std_matchers/mod.rs +++ b/src/m_value/std_matchers/mod.rs @@ -1 +1,7 @@ +pub mod absent; pub mod exists; +pub mod regex; + +pub use absent::Absent; +pub use exists::Exists; +pub use regex::Regex; diff --git a/src/m_value/std_matchers/regex.rs b/src/m_value/std_matchers/regex.rs new file mode 100644 index 0000000..98b8a8e --- /dev/null +++ b/src/m_value/std_matchers/regex.rs @@ -0,0 +1,82 @@ +use crate::{ + m_value::{m_value::MValue, match_processor::MatchProcessor}, + progress_println, +}; + +#[derive(Default)] +pub struct Regex; + +impl Regex { + pub fn new() -> Box { + Box::new(Regex) + } +} + +impl MatchProcessor for Regex { + fn key(&self) -> String { + String::from("$regex") + } + + fn is_match(&self, args: &MValue, value: &MValue) -> bool { + match (args, value) { + (MValue::String(args), MValue::String(value)) => { + match regex_match(args.clone(), value.clone()) { + Ok(result) => result, + Err(_) => { + progress_println!("Invalid regex: {}\nBe sure to include '/' characters around your regex matcher.", args); + false + } + } + } + (MValue::String(_args), _) => false, + _ => { + progress_println!("Invalid regex type: {}\nBe sure to include '/' characters around your regex matcher.", args); + false + } + } + } +} + +fn regex_match(args: String, value: String) -> Result { + let first_char = args.chars().nth(0).ok_or(())?; + let last_char = args.chars().last().ok_or(())?; + + let regex_str = match (first_char, last_char) { + ('/', '/') => &args[1..args.len() - 1], + _ => return Err(()), + }; + + let regex = regex::Regex::new(regex_str).map_err(|_| ())?; + Ok(regex.is_match(&value)) +} + +#[cfg(test)] +mod test { + use serde_yaml::Number; + + use super::*; + + #[test] + fn matches_with_valid_regex() { + let regex = Regex::new(); + let args = MValue::String(String::from("/^abc$/")); + let value = MValue::String(String::from("abc")); + assert!(regex.is_match(&args, &value)); + } + + #[test] + fn fails_with_invalid_regex() { + let regex = Regex::new(); + let args = MValue::String(String::from("^abc$")); + let value = MValue::String(String::from("abc")); + assert!(!regex.is_match(&args, &value)); + } + + #[test] + fn fails_with_invalid_regex_non_str() { + let regex = Regex::new(); + let args = MValue::Number(Number::from(1)); + let value = MValue::String(String::from("1")); + assert!(!regex.is_match(&args, &value)); + } +} From f5d5854ec21e42cad77705a3b782100a5cfe46a4 Mon Sep 17 00:00:00 2001 From: Wvaviator Date: Fri, 16 Feb 2024 14:04:26 -0600 Subject: [PATCH 13/34] add remaining std matchers --- src/m_value/matcher_map.rs | 3 + src/m_value/std_matchers/absent.rs | 3 +- src/m_value/std_matchers/empty.rs | 27 +++++ src/m_value/std_matchers/exists.rs | 3 +- src/m_value/std_matchers/includes.rs | 25 +++++ src/m_value/std_matchers/length.rs | 158 +++++++++++++++++++++++++++ src/m_value/std_matchers/mod.rs | 6 + src/m_value/std_matchers/regex.rs | 5 +- 8 files changed, 227 insertions(+), 3 deletions(-) create mode 100644 src/m_value/std_matchers/empty.rs create mode 100644 src/m_value/std_matchers/includes.rs create mode 100644 src/m_value/std_matchers/length.rs diff --git a/src/m_value/matcher_map.rs b/src/m_value/matcher_map.rs index 9f1dff5..a03a2ef 100644 --- a/src/m_value/matcher_map.rs +++ b/src/m_value/matcher_map.rs @@ -17,6 +17,9 @@ impl MatcherMap { map.insert_mp(Exists::new()); map.insert_mp(Regex::new()); map.insert_mp(Absent::new()); + map.insert_mp(Empty::new()); + map.insert_mp(Includes::new()); + map.insert_mp(Length::new()); map } diff --git a/src/m_value/std_matchers/absent.rs b/src/m_value/std_matchers/absent.rs index 2add7fe..a8bceb1 100644 --- a/src/m_value/std_matchers/absent.rs +++ b/src/m_value/std_matchers/absent.rs @@ -1,6 +1,7 @@ use crate::m_value::{m_value::MValue, match_processor::MatchProcessor}; -#[derive(Default)] +/// The absent matcher returns true if the expected value is missing or null. +/// Returns false if any other kind of value is found. pub struct Absent; impl Absent { diff --git a/src/m_value/std_matchers/empty.rs b/src/m_value/std_matchers/empty.rs new file mode 100644 index 0000000..e3420e1 --- /dev/null +++ b/src/m_value/std_matchers/empty.rs @@ -0,0 +1,27 @@ +use crate::m_value::{m_value::MValue, match_processor::MatchProcessor}; + +/// the $empty matcher checks to see if the provided value is empty. +/// Works with arrays, objects, and strings. Does not match null (use $absent for null checks). +/// Returns true if the given value effectively has a length of 0, false if not. +pub struct Empty; + +impl Empty { + pub fn new() -> Box { + Box::new(Empty) + } +} + +impl MatchProcessor for Empty { + fn key(&self) -> String { + String::from("$empty") + } + + fn is_match(&self, _args: &MValue, value: &MValue) -> bool { + match value { + MValue::Sequence(arr) => arr.is_empty(), + MValue::String(s) => s.is_empty(), + MValue::Mapping(map) => map.is_empty(), + _ => false, + } + } +} diff --git a/src/m_value/std_matchers/exists.rs b/src/m_value/std_matchers/exists.rs index 9552bec..a6d5854 100644 --- a/src/m_value/std_matchers/exists.rs +++ b/src/m_value/std_matchers/exists.rs @@ -1,6 +1,7 @@ use crate::m_value::{m_value::MValue, match_processor::MatchProcessor}; -#[derive(Default)] +/// The $exists matcher returns true if something exists at the expected value. +/// Returns false if no value is found or if the value is null. pub struct Exists; impl Exists { diff --git a/src/m_value/std_matchers/includes.rs b/src/m_value/std_matchers/includes.rs new file mode 100644 index 0000000..2d22d6b --- /dev/null +++ b/src/m_value/std_matchers/includes.rs @@ -0,0 +1,25 @@ +use crate::m_value::{m_value::MValue, match_processor::MatchProcessor}; + +/// The $includes matcher checks an array to see if the provided value is included. +/// Returns true if a matching (following standard matching rules) value is found in the array. +/// Returns false if no match is found. +pub struct Includes; + +impl Includes { + pub fn new() -> Box { + Box::new(Includes) + } +} + +impl MatchProcessor for Includes { + fn key(&self) -> String { + String::from("$includes") + } + + fn is_match(&self, args: &MValue, value: &MValue) -> bool { + match value { + MValue::Sequence(arr) => arr.iter().any(|i| i == args), + _ => false, + } + } +} diff --git a/src/m_value/std_matchers/length.rs b/src/m_value/std_matchers/length.rs new file mode 100644 index 0000000..19698a9 --- /dev/null +++ b/src/m_value/std_matchers/length.rs @@ -0,0 +1,158 @@ +use colored::Colorize; + +use crate::{ + m_value::{m_value::MValue, match_processor::MatchProcessor}, + progress_println, +}; + +/// The $length matcher checks the length of a sequence, string, or mapping. +/// Args can be either an exact numeric value, or a string with an operator and a value. +/// The operators are: '==', '<=', '>=', '<', '>' +/// Returns true if the length of the value matches the args. +pub struct Length; + +impl Length { + pub fn new() -> Box { + Box::new(Length) + } +} + +impl MatchProcessor for Length { + fn key(&self) -> String { + String::from("$length") + } + + fn is_match(&self, args: &MValue, value: &MValue) -> bool { + let matcher = LengthMatcher::from(args); + + match value { + MValue::Sequence(arr) => matcher == arr.len(), + MValue::String(s) => matcher == s.len(), + MValue::Mapping(map) => matcher == map.len(), + _ => false, + } + } +} + +enum LengthMatcher { + Equal(usize), + LessThan(usize), + GreaterThan(usize), + LessEqual(usize), + GreaterEqual(usize), +} + +impl PartialEq for LengthMatcher { + fn eq(&self, other: &usize) -> bool { + match self { + LengthMatcher::Equal(n) => n == other, + LengthMatcher::LessThan(n) => other < n, + LengthMatcher::GreaterThan(n) => other > n, + LengthMatcher::LessEqual(n) => other <= n, + LengthMatcher::GreaterEqual(n) => other >= n, + } + } +} + +impl From<&MValue> for LengthMatcher { + fn from(value: &MValue) -> Self { + match value { + MValue::Number(n) => LengthMatcher::Equal(n.as_u64().unwrap_or(0) as usize), + MValue::String(s) => match s.as_str() { + s if s.starts_with("==") => { + let value = s[2..].trim().parse::().unwrap_or_else(|_| { + progress_println!( + "Invalid length matcher {}. Proper format is {}", + s.red(), + "'== '".green() + ); + 0 + }); + LengthMatcher::Equal(value) + } + s if s.starts_with("<=") => { + let value = s[2..].trim().parse::().unwrap_or_else(|_| { + progress_println!( + "Invalid length matcher {}. Proper format is {}", + s.red(), + "'<= '".green() + ); + 0 + }); + LengthMatcher::LessEqual(value) + } + s if s.starts_with(">=") => { + let value = s[2..].trim().parse::().unwrap_or_else(|_| { + progress_println!( + "Invalid length matcher {}. Proper format is {}", + s.red(), + "'>= '".green() + ); + 0 + }); + LengthMatcher::GreaterEqual(value) + } + s if s.starts_with("<") => { + let value = s[1..].trim().parse::().unwrap_or_else(|_| { + progress_println!( + "Invalid length matcher {}. Proper format is {}", + s.red(), + "'< '".green() + ); + 0 + }); + LengthMatcher::LessThan(value) + } + s if s.starts_with(">") => { + let value = s[1..].trim().parse::().unwrap_or_else(|_| { + progress_println!( + "Invalid length matcher {}. Proper format is {}", + s.red(), + "'> '".green() + ); + 0 + }); + LengthMatcher::GreaterThan(value) + } + _ => { + progress_println!("Invalid length matcher {}. Comparison operator must be one of {}, {}, {}, {}, or {}.", s.red(), "'=='".green(), "'<='".green(), "'>='".green(), "'<'".green(), "'>'".green()); + LengthMatcher::Equal(0) + } + }, + + _ => { + progress_println!("Invalid value for $length matcher. Must be a number or string in the format '>= 4' or '< 5'"); + LengthMatcher::Equal(0) + } + } + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn works_with_numeric_matcher() { + let matcher = Length::new(); + let args = MValue::Number(5.into()); + let value = MValue::Sequence(vec![MValue::Null; 5]); + assert!(matcher.is_match(&args, &value)); + } + + #[test] + fn works_with_string_matcher() { + let matcher = Length::new(); + let args = MValue::String("<= 6".to_string()); + let value = MValue::String("hello".to_string()); + assert!(matcher.is_match(&args, &value)); + } + + #[test] + fn works_with_lt_matcher() { + let matcher = Length::new(); + let args = MValue::String("< 5".to_string()); + let value = MValue::String("hello".to_string()); + assert!(!matcher.is_match(&args, &value)); + } +} diff --git a/src/m_value/std_matchers/mod.rs b/src/m_value/std_matchers/mod.rs index cfc629d..d7a6e0e 100644 --- a/src/m_value/std_matchers/mod.rs +++ b/src/m_value/std_matchers/mod.rs @@ -1,7 +1,13 @@ pub mod absent; +pub mod empty; pub mod exists; +pub mod includes; +pub mod length; pub mod regex; pub use absent::Absent; +pub use empty::Empty; pub use exists::Exists; +pub use includes::Includes; +pub use length::Length; pub use regex::Regex; diff --git a/src/m_value/std_matchers/regex.rs b/src/m_value/std_matchers/regex.rs index 98b8a8e..6fe00e0 100644 --- a/src/m_value/std_matchers/regex.rs +++ b/src/m_value/std_matchers/regex.rs @@ -3,7 +3,10 @@ use crate::{ progress_println, }; -#[derive(Default)] +/// The $regex matcher takes in a regex wrapped by '/' characters, and determines whether a match +/// can be found in the provided value. +/// Returns true if a match is found anywhere in the value. +/// Returns false if no match is found. pub struct Regex; impl Regex { From ae52b993ae34208d555cf6df87f37d2a67716a16 Mon Sep 17 00:00:00 2001 From: Wvaviator Date: Fri, 16 Feb 2024 15:52:15 -0600 Subject: [PATCH 14/34] start new mvalue integration --- src/m_value/m_map.rs | 34 +++++++++++- src/m_value/m_value.rs | 28 +++++++++- src/m_value/matcher_definition.rs | 11 ++++ src/m_value/mod.rs | 1 + src/m_value/status_matcher.rs | 50 +++++++++++++++++ src/suite/extract/extract.rs | 36 ++++++++----- src/suite/response/mod.rs | 2 +- src/suite/response/response_definition.rs | 35 ++++++------ src/suite/response/response_headers.rs | 40 +++++++++----- src/suite/response/status.rs | 65 +++++++++++++++++++++++ src/suite/suite.rs | 1 - src/suite/test.rs | 36 ++++++------- 12 files changed, 267 insertions(+), 72 deletions(-) create mode 100644 src/m_value/status_matcher.rs create mode 100644 src/suite/response/status.rs diff --git a/src/m_value/m_map.rs b/src/m_value/m_map.rs index 9ac3795..ae6566e 100644 --- a/src/m_value/m_map.rs +++ b/src/m_value/m_map.rs @@ -6,11 +6,11 @@ use std::{ }; use indexmap::IndexMap; -use serde::{Deserialize, Deserializer}; +use serde::{Deserialize, Deserializer, Serialize}; use super::m_value::MValue; -#[derive(Debug, Clone)] +#[derive(Debug, Default, Clone)] pub struct Mapping { map: IndexMap, } @@ -30,6 +30,20 @@ impl Mapping { } } +impl Serialize for Mapping { + fn serialize(&self, serializer: S) -> Result + where + S: serde::ser::Serializer, + { + use serde::ser::SerializeMap; + let mut map = serializer.serialize_map(Some(self.map.len()))?; + for (k, v) in &self.map { + map.serialize_entry(k, v)?; + } + map.end() + } +} + impl PartialEq for Mapping { fn eq(&self, other: &Self) -> bool { for (k, v) in &self.map { @@ -42,6 +56,22 @@ impl PartialEq for Mapping { } } +impl PartialEq for Option { + fn eq(&self, other: &Mapping) -> bool { + match self { + Some(mapping) => mapping.eq(other), + None => true, + } + } +} + +impl FromIterator<(MValue, MValue)> for Mapping { + fn from_iter>(iter: T) -> Self { + let map = iter.into_iter().collect::>(); + Mapping { map } + } +} + impl Hash for Mapping { fn hash(&self, state: &mut H) { let mut xor = 0; diff --git a/src/m_value/m_value.rs b/src/m_value/m_value.rs index b7bc702..e034248 100644 --- a/src/m_value/m_value.rs +++ b/src/m_value/m_value.rs @@ -2,7 +2,7 @@ use std::fmt; use serde::{ de::{self, MapAccess, SeqAccess, Visitor}, - Deserialize, Deserializer, + Deserialize, Deserializer, Serialize, }; use serde_yaml::Number; @@ -27,6 +27,23 @@ impl Default for MValue { } } +impl Serialize for MValue { + fn serialize(&self, serializer: S) -> Result + where + S: serde::ser::Serializer, + { + match self { + MValue::Null => serializer.serialize_unit(), + MValue::Bool(b) => serializer.serialize_bool(*b), + MValue::Number(n) => n.serialize(serializer), + MValue::String(s) => serializer.serialize_str(s), + MValue::Sequence(arr) => arr.serialize(serializer), + MValue::Mapping(m) => m.serialize(serializer), + MValue::Matcher(m) => m.serialize(serializer), + } + } +} + impl Eq for MValue {} pub type Sequence = Vec; @@ -151,6 +168,15 @@ impl PartialEq for MValue { } } +impl PartialEq for Option { + fn eq(&self, other: &MValue) -> bool { + match self { + Some(value) => value == other, + None => true, + } + } +} + impl SuiteVariables for MValue { fn populate_variables( &mut self, diff --git a/src/m_value/matcher_definition.rs b/src/m_value/matcher_definition.rs index 1f0e29e..b4d8c30 100644 --- a/src/m_value/matcher_definition.rs +++ b/src/m_value/matcher_definition.rs @@ -1,5 +1,7 @@ use std::fmt; +use serde::Serialize; + use super::{m_value::MValue, matcher_map::MatcherMap}; #[derive(Debug, Clone, PartialEq, Hash)] @@ -8,6 +10,15 @@ pub struct MatcherDefintion { args: MValue, } +impl Serialize for MatcherDefintion { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&format!("{} {}", self.match_key, self.args)) + } +} + impl MatcherDefintion { pub fn is_match(&self, value: &MValue) -> bool { if let Some(matcher) = MatcherMap::get_matcher(&self.match_key) { diff --git a/src/m_value/mod.rs b/src/m_value/mod.rs index 410c9ed..1b5a36a 100644 --- a/src/m_value/mod.rs +++ b/src/m_value/mod.rs @@ -3,4 +3,5 @@ pub mod m_value; pub mod match_processor; pub mod matcher_definition; pub mod matcher_map; +pub mod status_matcher; pub mod std_matchers; diff --git a/src/m_value/status_matcher.rs b/src/m_value/status_matcher.rs new file mode 100644 index 0000000..ae12b92 --- /dev/null +++ b/src/m_value/status_matcher.rs @@ -0,0 +1,50 @@ +use std::fmt; + +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum StatusMatcher { + Exact(u16), + Class(String), +} + +impl PartialEq for StatusMatcher { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (StatusMatcher::Exact(a), StatusMatcher::Exact(b)) => a.eq(b), + (StatusMatcher::Class(a), StatusMatcher::Class(b)) => a.eq(b), + (StatusMatcher::Class(c), StatusMatcher::Exact(n)) => match c.as_str() { + "2xx" => (200..300).contains(n), + "3xx" => (300..400).contains(n), + "4xx" => (400..500).contains(n), + "5xx" => (500..600).contains(n), + _ => false, + }, + _ => false, + } + } +} + +impl From for StatusMatcher { + fn from(status: reqwest::StatusCode) -> Self { + StatusMatcher::Exact(status.as_u16()) + } +} + +impl fmt::Display for StatusMatcher { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + StatusMatcher::Exact(n) => { + writeln!(f, "Status: {}", n) + } + StatusMatcher::Class(s) => match s.as_str() { + "2xx" => write!(f, "200-299"), + "3xx" => write!(f, "300-399"), + "4xx" => write!(f, "400-499"), + "5xx" => write!(f, "500-599"), + _ => write!(f, "Invalid status range"), + }, + } + } +} diff --git a/src/suite/extract/extract.rs b/src/suite/extract/extract.rs index 403b473..2015b32 100644 --- a/src/suite/extract/extract.rs +++ b/src/suite/extract/extract.rs @@ -1,14 +1,15 @@ -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use crate::{ errors::CaptiError, + m_value::m_value::MValue, suite::response::{response_headers::ResponseHeaders, ResponseDefinition}, variables::variable_map::VariableMap, }; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, PartialEq)] pub struct ResponseExtractor { - body: serde_json::Value, + body: MValue, headers: Option, } @@ -20,19 +21,28 @@ impl ResponseExtractor { ) -> Result<(), CaptiError> { let response_body = match &response.body { Some(body) => body, - None => &serde_json::Value::Null, + None => &MValue::Null, }; body_extract(&self.body, response_body, variables)?; if let Some(headers) = &self.headers { for (key, value) in headers.iter() { + let value = match value { + MValue::String(s) => s, + _ => { + return Err(CaptiError::extract_error(format!( + "Invalid value for header {} in response.", + &key + ))) + } + }; match &response.headers { Some(response_headers) => match response_headers.get(key) { - Some(header_value) => { + Some(MValue::String(header_value)) => { variables.extract_variables(value, header_value)?; } - None => { + _ => { return Err(CaptiError::extract_error(format!( "Missing header {} in response.", &key @@ -54,14 +64,14 @@ impl ResponseExtractor { } fn body_extract( - left: &serde_json::Value, - right: &serde_json::Value, + left: &MValue, + right: &MValue, variables: &mut VariableMap, ) -> Result<(), CaptiError> { match (left, right) { - (serde_json::Value::Null, _) => {} - (serde_json::Value::Object(left), serde_json::Value::Object(right)) => { - for (key, value) in left { + (MValue::Null, _) => {} + (MValue::Mapping(left), MValue::Mapping(right)) => { + for (key, value) in left.iter() { match right.get(key) { Some(right_value) => body_extract(value, right_value, variables)?, None => { @@ -73,12 +83,12 @@ fn body_extract( } } } - (serde_json::Value::Array(left), serde_json::Value::Array(right)) => { + (MValue::Sequence(left), MValue::Sequence(right)) => { for (i, value) in left.iter().enumerate() { body_extract(value, &right[i], variables)?; } } - (serde_json::Value::String(left), serde_json::Value::String(right)) => { + (MValue::String(left), MValue::String(right)) => { variables.extract_variables(left, right).map_err(|_| { CaptiError::extract_error(format!( "Failed to extract variables from '{}' using matcher '{}'.", diff --git a/src/suite/response/mod.rs b/src/suite/response/mod.rs index 6d38980..047a9ea 100644 --- a/src/suite/response/mod.rs +++ b/src/suite/response/mod.rs @@ -1,5 +1,5 @@ pub mod response_definition; pub mod response_headers; +pub mod status; pub use response_definition::ResponseDefinition; - diff --git a/src/suite/response/response_definition.rs b/src/suite/response/response_definition.rs index c9632cd..d8a076c 100644 --- a/src/suite/response/response_definition.rs +++ b/src/suite/response/response_definition.rs @@ -1,26 +1,26 @@ use std::fmt; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use crate::{ errors::CaptiError, - matcher::{match_result::MatchResult, status_matcher::StatusMatcher, MatchCmp}, + m_value::{m_value::MValue, status_matcher::StatusMatcher}, suite::test::TestResult, variables::{variable_map::VariableMap, SuiteVariables}, }; -use super::response_headers::ResponseHeaders; +use super::{response_headers::ResponseHeaders, status::Status}; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, PartialEq)] pub struct ResponseDefinition { - pub status: Option, + pub status: Status, pub headers: Option, - pub body: Option, + pub body: Option, } impl ResponseDefinition { pub async fn from_response(response: reqwest::Response) -> Self { - let status = Some(StatusMatcher::Exact(response.status().as_u16())); + let status = Status::from(StatusMatcher::Exact(response.status().as_u16())); let headers = ResponseHeaders::from(response.headers()); let headers = match headers.len() { @@ -28,7 +28,7 @@ impl ResponseDefinition { _ => Some(headers), }; - let body = match response.json::().await { + let body = match response.json::().await { Ok(body) => Some(body), Err(_) => None, }; @@ -41,19 +41,16 @@ impl ResponseDefinition { } pub fn compare(&self, other: &ResponseDefinition) -> TestResult { - match self.status.match_cmp(&other.status) { - MatchResult::Matches => {} - other => return TestResult::fail("Status does not match.", &other), + if !self.status.eq(&other.status) { + return TestResult::fail("Status does not match."); } - match self.headers.match_cmp(&other.headers) { - MatchResult::Matches => {} - other => return TestResult::fail("Headers do not match.", &other), + if !self.headers.eq(&other.headers) { + return TestResult::fail("Headers do not match."); } - match self.body.match_cmp(&other.body) { - MatchResult::Matches => {} - other => return TestResult::fail("Body does not match.", &other), + if !self.body.eq(&other.body) { + return TestResult::fail("Body does not match."); } return TestResult::Passed; @@ -73,9 +70,7 @@ impl fmt::Display for ResponseDefinition { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { writeln!(f, " ")?; - if let Some(status) = &self.status { - writeln!(f, " {}", status)?; - } + writeln!(f, " {}", self.status)?; if let Some(headers) = &self.headers { writeln!(f, " {}", headers)?; diff --git a/src/suite/response/response_headers.rs b/src/suite/response/response_headers.rs index cfe9f80..b5e2f09 100644 --- a/src/suite/response/response_headers.rs +++ b/src/suite/response/response_headers.rs @@ -1,35 +1,48 @@ use crate::{ errors::CaptiError, - matcher::{MatchCmp, MatchResult}, + m_value::{m_map::Mapping, m_value::MValue}, variables::{variable_map::VariableMap, SuiteVariables}, }; use reqwest::header::HeaderMap; -use serde::{Deserialize, Serialize}; -use std::{collections::HashMap, fmt, ops::Deref}; +use serde::Deserialize; +use std::{fmt, ops::Deref}; -#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Default, Clone, Deserialize)] #[serde(transparent)] -pub struct ResponseHeaders(HashMap); +pub struct ResponseHeaders(Mapping); impl SuiteVariables for ResponseHeaders { fn populate_variables(&mut self, variables: &mut VariableMap) -> Result<(), CaptiError> { for (_, value) in self.0.iter_mut() { - *value = variables.replace_variables(value.as_str())?; + value.populate_variables(variables)?; } Ok(()) } } -impl MatchCmp for ResponseHeaders { - fn match_cmp(&self, other: &Self) -> MatchResult { +impl PartialEq for ResponseHeaders { + fn eq(&self, other: &Self) -> bool { let lowercase_headers = self .0 .iter() - .map(|(key, value)| (key.to_lowercase(), value.clone())) - .collect::>(); + .map(|(key, value)| { + let key = match key { + MValue::String(s) => MValue::String(s.to_lowercase()), + other => other.clone(), + }; + (key, value.clone()) + }) + .collect::(); + + lowercase_headers.eq(&other.0) + } +} - lowercase_headers.match_cmp(&other.0) +impl FromIterator<(MValue, MValue)> for ResponseHeaders { + fn from_iter>(iter: T) -> Self { + let map = iter.into_iter().collect::(); + ResponseHeaders(map) } } @@ -54,14 +67,15 @@ impl From<&HeaderMap> for ResponseHeaders { Some((header, value.to_string())) }) - .collect::>(); + .map(|(key, value)| (MValue::String(key), MValue::String(value))) + .collect::(); return ResponseHeaders(headers); } } impl Deref for ResponseHeaders { - type Target = HashMap; + type Target = Mapping; fn deref(&self) -> &Self::Target { &self.0 } diff --git a/src/suite/response/status.rs b/src/suite/response/status.rs new file mode 100644 index 0000000..6a5c0fa --- /dev/null +++ b/src/suite/response/status.rs @@ -0,0 +1,65 @@ +use std::{fmt, ops::Deref}; + +use serde::Deserialize; + +use crate::m_value::status_matcher::StatusMatcher; + +#[derive(Debug, Clone, Deserialize)] +#[serde(transparent)] +pub struct Status(Option); + +impl Status { + pub fn none() -> Self { + Status(None) + } +} + +impl Deref for Status { + type Target = Option; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl PartialEq for Status { + fn eq(&self, other: &Status) -> bool { + match (&self.0, &other.0) { + (Some(a), Some(b)) => a.eq(b), + (None, _) => true, + (_, None) => false, + } + } +} + +impl From<&str> for Status { + fn from(value: &str) -> Self { + match value { + "2xx" => Status(Some(StatusMatcher::Class(String::from("2xx")))), + "3xx" => Status(Some(StatusMatcher::Class(String::from("3xx")))), + "4xx" => Status(Some(StatusMatcher::Class(String::from("4xx")))), + "5xx" => Status(Some(StatusMatcher::Class(String::from("5xx")))), + _ => Status(None), + } + } +} + +impl From for Status { + fn from(status: StatusMatcher) -> Self { + Status(Some(status)) + } +} + +impl From for Status { + fn from(status: u16) -> Self { + Status(Some(StatusMatcher::Exact(status))) + } +} + +impl fmt::Display for Status { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.0 { + Some(status) => write!(f, "{}", status), + None => write!(f, "None"), + } + } +} diff --git a/src/suite/suite.rs b/src/suite/suite.rs index 6bf040a..88544ca 100644 --- a/src/suite/suite.rs +++ b/src/suite/suite.rs @@ -118,6 +118,5 @@ mod test { assert_eq!(suite.suite, String::from("Simple Get Request Tests")); assert_eq!(suite.tests[0].request.method, RequestMethod::Get); - assert_eq!(suite.tests[0].expect.body.as_ref().unwrap()["id"], 1); } } diff --git a/src/suite/test.rs b/src/suite/test.rs index cc46152..767ecc6 100644 --- a/src/suite/test.rs +++ b/src/suite/test.rs @@ -2,13 +2,12 @@ use std::fmt::{self, Debug}; use colored::Colorize; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use crate::{ client::Client, errors::CaptiError, formatting::Heading, - matcher::match_result::MatchResult, progress::Spinner, progress_println, variables::{variable_map::VariableMap, SuiteVariables}, @@ -19,7 +18,7 @@ use super::{ response::ResponseDefinition, }; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, PartialEq)] pub struct TestDefinition { pub test: String, pub description: Option, @@ -70,10 +69,9 @@ impl TestDefinition { let test_result = self.expect.compare(&response); let test_result = match (test_result, self.should_fail) { - (TestResult::Passed, true) => TestResult::Failed(FailureReport::new( - "Expected failure, but test passed.", - MatchResult::Matches, - )), + (TestResult::Passed, true) => { + TestResult::Failed(FailureReport::new("Expected failure, but test passed.")) + } (TestResult::Failed(_), true) => TestResult::Passed, (result, _) => result, }; @@ -111,8 +109,8 @@ pub enum TestResult { } impl TestResult { - pub fn fail(message: impl Into, match_result: &MatchResult) -> Self { - TestResult::Failed(FailureReport::new(message, match_result.clone())) + pub fn fail(message: impl Into) -> Self { + TestResult::Failed(FailureReport::new(message)) } } @@ -134,14 +132,12 @@ impl Into for &TestResult { #[derive(Debug, Clone, PartialEq, Eq)] pub struct FailureReport { message: String, - match_result: MatchResult, } impl FailureReport { - pub fn new(message: impl Into, match_result: MatchResult) -> Self { + pub fn new(message: impl Into) -> Self { FailureReport { message: message.into(), - match_result, } } } @@ -149,7 +145,6 @@ impl FailureReport { impl fmt::Display for FailureReport { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { writeln!(f, "{}", self.message)?; - writeln!(f, "{}", self.match_result)?; Ok(()) } @@ -158,10 +153,9 @@ impl fmt::Display for FailureReport { #[cfg(test)] mod test { - use serde_json::json; - use crate::{ - matcher::status_matcher::StatusMatcher, suite::response::response_headers::ResponseHeaders, + m_value::{m_value::MValue, status_matcher::StatusMatcher}, + suite::response::{response_headers::ResponseHeaders, status::Status}, }; use super::*; @@ -171,12 +165,12 @@ mod test { let matcher = ResponseDefinition { headers: None, body: None, - status: None, + status: Status::none(), }; let response = ResponseDefinition { headers: Some(ResponseHeaders::default()), - body: Some(json!({ "test": "test" })), - status: Some(StatusMatcher::Exact(200)), + body: Some(serde_json::from_str::(r#"{"test": "test"}"#).unwrap()), + status: Status::from(200), }; assert_eq!(matcher.compare(&response), TestResult::Passed); @@ -187,12 +181,12 @@ mod test { let matcher = ResponseDefinition { headers: None, body: None, - status: Some(StatusMatcher::Class(String::from("2xx"))), + status: Status::from("2xx"), }; let response = ResponseDefinition { headers: None, body: None, - status: Some(StatusMatcher::Exact(200)), + status: Status::from(200), }; assert_eq!(matcher.compare(&response), TestResult::Passed); From 4b18a0bd144f9eed398abb2ff15efb440506d047 Mon Sep 17 00:00:00 2001 From: Wvaviator Date: Fri, 16 Feb 2024 17:11:10 -0600 Subject: [PATCH 15/34] new implementation functioning --- src/m_value/m_map.rs | 24 +++++++++- src/m_value/m_value.rs | 13 +++-- src/m_value/matcher_definition.rs | 14 +++++- src/m_value/std_matchers/includes.rs | 2 +- src/suite/extract/extract.rs | 58 +++++++++-------------- src/suite/response/response_definition.rs | 28 +++++------ src/suite/test.rs | 18 +++---- 7 files changed, 85 insertions(+), 72 deletions(-) diff --git a/src/m_value/m_map.rs b/src/m_value/m_map.rs index ae6566e..520b728 100644 --- a/src/m_value/m_map.rs +++ b/src/m_value/m_map.rs @@ -8,6 +8,12 @@ use std::{ use indexmap::IndexMap; use serde::{Deserialize, Deserializer, Serialize}; +use crate::{ + errors::CaptiError, + progress_println, + variables::{variable_map::VariableMap, SuiteVariables}, +}; + use super::m_value::MValue; #[derive(Debug, Default, Clone)] @@ -30,6 +36,15 @@ impl Mapping { } } +impl SuiteVariables for Mapping { + fn populate_variables(&mut self, variables: &mut VariableMap) -> Result<(), CaptiError> { + for value in self.map.values_mut() { + value.populate_variables(variables)?; + } + Ok(()) + } +} + impl Serialize for Mapping { fn serialize(&self, serializer: S) -> Result where @@ -47,7 +62,14 @@ impl Serialize for Mapping { impl PartialEq for Mapping { fn eq(&self, other: &Self) -> bool { for (k, v) in &self.map { - if v != other.get(k).unwrap_or(&MValue::Null) { + let other_v = other.get(k).unwrap_or(&MValue::Null); + if v != other_v { + progress_println!( + "Mismatch at key {}:\n expected: {}\n found: {}", + &k, + &v, + &other_v + ); return false; } } diff --git a/src/m_value/m_value.rs b/src/m_value/m_value.rs index e034248..b44ee04 100644 --- a/src/m_value/m_value.rs +++ b/src/m_value/m_value.rs @@ -163,7 +163,8 @@ impl PartialEq for MValue { (Self::Sequence(l0), Self::Sequence(r0)) => l0 == r0, (Self::Mapping(l0), Self::Mapping(r0)) => l0 == r0, (Self::Matcher(l0), other) => l0.is_match(&other), - _ => core::mem::discriminant(self) == core::mem::discriminant(other), + (Self::Null, _) => true, + _ => false, } } } @@ -184,18 +185,16 @@ impl SuiteVariables for MValue { ) -> Result<(), crate::errors::CaptiError> { match self { MValue::String(s) => { - *s = variables.replace_variables(&s)?; + let new_s = variables.replace_variables(&s)?; + *s = new_s; } MValue::Sequence(seq) => { for value in seq { value.populate_variables(variables)?; } } - MValue::Mapping(mapping) => { - for value in mapping.values_mut() { - value.populate_variables(variables)?; - } - } + MValue::Mapping(mapping) => mapping.populate_variables(variables)?, + MValue::Matcher(m) => m.populate_variables(variables)?, _ => {} } diff --git a/src/m_value/matcher_definition.rs b/src/m_value/matcher_definition.rs index b4d8c30..1e3f526 100644 --- a/src/m_value/matcher_definition.rs +++ b/src/m_value/matcher_definition.rs @@ -2,6 +2,11 @@ use std::fmt; use serde::Serialize; +use crate::{ + errors::CaptiError, + variables::{variable_map::VariableMap, SuiteVariables}, +}; + use super::{m_value::MValue, matcher_map::MatcherMap}; #[derive(Debug, Clone, PartialEq, Hash)] @@ -29,6 +34,13 @@ impl MatcherDefintion { } } +impl SuiteVariables for MatcherDefintion { + fn populate_variables(&mut self, variables: &mut VariableMap) -> Result<(), CaptiError> { + self.args.populate_variables(variables)?; + Ok(()) + } +} + impl fmt::Display for MatcherDefintion { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{} {}", self.match_key, self.args)?; @@ -44,7 +56,7 @@ impl TryFrom<&str> for MatcherDefintion { if let Some(key_candidate) = parts.next() { if let Some(_) = MatcherMap::get_matcher(key_candidate) { let args = parts.map(|s| s.into()).collect::>().join(" "); - let args = serde_json::from_str::(&args).unwrap_or(MValue::Null); + let args = serde_yaml::from_str::(&args).unwrap_or(MValue::Null); return Ok(MatcherDefintion { match_key: key_candidate.to_string(), args, diff --git a/src/m_value/std_matchers/includes.rs b/src/m_value/std_matchers/includes.rs index 2d22d6b..a42577f 100644 --- a/src/m_value/std_matchers/includes.rs +++ b/src/m_value/std_matchers/includes.rs @@ -18,7 +18,7 @@ impl MatchProcessor for Includes { fn is_match(&self, args: &MValue, value: &MValue) -> bool { match value { - MValue::Sequence(arr) => arr.iter().any(|i| i == args), + MValue::Sequence(arr) => arr.iter().any(|i| args.eq(i)), _ => false, } } diff --git a/src/suite/extract/extract.rs b/src/suite/extract/extract.rs index 2015b32..3ccfbaa 100644 --- a/src/suite/extract/extract.rs +++ b/src/suite/extract/extract.rs @@ -10,7 +10,8 @@ use crate::{ #[derive(Debug, Clone, Deserialize, PartialEq)] pub struct ResponseExtractor { body: MValue, - headers: Option, + #[serde(default)] + headers: ResponseHeaders, } impl ResponseExtractor { @@ -19,42 +20,27 @@ impl ResponseExtractor { response: &ResponseDefinition, variables: &mut VariableMap, ) -> Result<(), CaptiError> { - let response_body = match &response.body { - Some(body) => body, - None => &MValue::Null, - }; + body_extract(&self.body, &response.body, variables)?; - body_extract(&self.body, response_body, variables)?; - - if let Some(headers) = &self.headers { - for (key, value) in headers.iter() { - let value = match value { - MValue::String(s) => s, - _ => { - return Err(CaptiError::extract_error(format!( - "Invalid value for header {} in response.", - &key - ))) - } - }; - match &response.headers { - Some(response_headers) => match response_headers.get(key) { - Some(MValue::String(header_value)) => { - variables.extract_variables(value, header_value)?; - } - _ => { - return Err(CaptiError::extract_error(format!( - "Missing header {} in response.", - &key - ))) - } - }, - None => { - return Err(CaptiError::extract_error(format!( - "Missing header {} in response.", - &key - ))) - } + for (key, value) in self.headers.iter() { + let value = match value { + MValue::String(s) => s, + _ => { + return Err(CaptiError::extract_error(format!( + "Invalid value for header {} in response.", + &key + ))) + } + }; + match &response.headers.get(key) { + Some(MValue::String(header_value)) => { + variables.extract_variables(value, header_value)?; + } + _ => { + return Err(CaptiError::extract_error(format!( + "Missing header {} in response.", + &key + ))) } } } diff --git a/src/suite/response/response_definition.rs b/src/suite/response/response_definition.rs index d8a076c..a8ee0f8 100644 --- a/src/suite/response/response_definition.rs +++ b/src/suite/response/response_definition.rs @@ -14,8 +14,10 @@ use super::{response_headers::ResponseHeaders, status::Status}; #[derive(Debug, Clone, Deserialize, PartialEq)] pub struct ResponseDefinition { pub status: Status, - pub headers: Option, - pub body: Option, + #[serde(default)] + pub headers: ResponseHeaders, + #[serde(default)] + pub body: MValue, } impl ResponseDefinition { @@ -23,14 +25,10 @@ impl ResponseDefinition { let status = Status::from(StatusMatcher::Exact(response.status().as_u16())); let headers = ResponseHeaders::from(response.headers()); - let headers = match headers.len() { - 0 => None, - _ => Some(headers), - }; let body = match response.json::().await { - Ok(body) => Some(body), - Err(_) => None, + Ok(body) => body, + Err(_) => MValue::Null, }; ResponseDefinition { @@ -72,16 +70,12 @@ impl fmt::Display for ResponseDefinition { writeln!(f, " {}", self.status)?; - if let Some(headers) = &self.headers { - writeln!(f, " {}", headers)?; - } + writeln!(f, " {}", self.headers)?; - if let Some(body) = &self.body { - if let Ok(json) = serde_json::to_string_pretty(&body) { - writeln!(f, " Body:")?; - for line in json.lines() { - writeln!(f, " {}", line)?; - } + if let Ok(json) = serde_json::to_string_pretty(&self.body) { + writeln!(f, " Body:")?; + for line in json.lines() { + writeln!(f, " {}", line)?; } } diff --git a/src/suite/test.rs b/src/suite/test.rs index 767ecc6..6da64e7 100644 --- a/src/suite/test.rs +++ b/src/suite/test.rs @@ -154,7 +154,7 @@ impl fmt::Display for FailureReport { mod test { use crate::{ - m_value::{m_value::MValue, status_matcher::StatusMatcher}, + m_value::m_value::MValue, suite::response::{response_headers::ResponseHeaders, status::Status}, }; @@ -163,13 +163,13 @@ mod test { #[test] fn test_compare_optional() { let matcher = ResponseDefinition { - headers: None, - body: None, + headers: ResponseHeaders::default(), + body: MValue::default(), status: Status::none(), }; let response = ResponseDefinition { - headers: Some(ResponseHeaders::default()), - body: Some(serde_json::from_str::(r#"{"test": "test"}"#).unwrap()), + headers: ResponseHeaders::default(), + body: serde_json::from_str::(r#"{"test": "test"}"#).unwrap(), status: Status::from(200), }; @@ -179,13 +179,13 @@ mod test { #[test] fn test_compare_status_matches() { let matcher = ResponseDefinition { - headers: None, - body: None, + headers: ResponseHeaders::default(), + body: MValue::Null, status: Status::from("2xx"), }; let response = ResponseDefinition { - headers: None, - body: None, + headers: ResponseHeaders::default(), + body: MValue::Null, status: Status::from(200), }; From 70e98ef2952d129940190e83666125aa17ce0d12 Mon Sep 17 00:00:00 2001 From: Wvaviator Date: Fri, 16 Feb 2024 18:17:03 -0600 Subject: [PATCH 16/34] add partialeq logging for mvalue --- src/m_value/m_map.rs | 10 ++++ src/m_value/m_sequence.rs | 47 +++++++++++++++ src/m_value/m_value.rs | 93 ++++++++++++++++++++++-------- src/m_value/mod.rs | 1 + src/m_value/std_matchers/length.rs | 4 +- 5 files changed, 130 insertions(+), 25 deletions(-) create mode 100644 src/m_value/m_sequence.rs diff --git a/src/m_value/m_map.rs b/src/m_value/m_map.rs index 520b728..dd22160 100644 --- a/src/m_value/m_map.rs +++ b/src/m_value/m_map.rs @@ -45,6 +45,16 @@ impl SuiteVariables for Mapping { } } +impl fmt::Display for Mapping { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{{ ")?; + for (key, value) in &self.map { + write!(f, "{}: {}, ", key, value)?; + } + write!(f, "}}") + } +} + impl Serialize for Mapping { fn serialize(&self, serializer: S) -> Result where diff --git a/src/m_value/m_sequence.rs b/src/m_value/m_sequence.rs new file mode 100644 index 0000000..d23b0e1 --- /dev/null +++ b/src/m_value/m_sequence.rs @@ -0,0 +1,47 @@ +use std::{ + fmt, + ops::{Deref, DerefMut}, +}; + +use serde::Deserialize; + +use super::m_value::MValue; + +#[derive(Debug, Default, Clone, Hash, Deserialize)] +#[serde(transparent)] +pub struct MSequence(Vec); + +impl Deref for MSequence { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for MSequence { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl PartialEq for MSequence { + fn eq(&self, other: &Self) -> bool { + self.0 == other.0 + } +} + +impl fmt::Display for MSequence { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "[")?; + for value in &self.0 { + write!(f, "{}, ", value)?; + } + write!(f, "]") + } +} + +impl From> for MSequence { + fn from(vec: Vec) -> Self { + MSequence(vec) + } +} diff --git a/src/m_value/m_value.rs b/src/m_value/m_value.rs index b44ee04..55424ce 100644 --- a/src/m_value/m_value.rs +++ b/src/m_value/m_value.rs @@ -6,9 +6,9 @@ use serde::{ }; use serde_yaml::Number; -use crate::variables::SuiteVariables; +use crate::{progress_println, variables::SuiteVariables}; -use super::{m_map::Mapping, matcher_definition::MatcherDefintion}; +use super::{m_map::Mapping, m_sequence::MSequence, matcher_definition::MatcherDefintion}; #[derive(Debug, Hash, Clone)] pub enum MValue { @@ -16,7 +16,7 @@ pub enum MValue { Bool(bool), Number(Number), String(String), - Sequence(Sequence), + Sequence(MSequence), Mapping(Mapping), Matcher(Box), } @@ -46,8 +46,6 @@ impl Serialize for MValue { impl Eq for MValue {} -pub type Sequence = Vec; - impl<'de> Deserialize<'de> for MValue { fn deserialize(deserializer: D) -> Result where @@ -136,7 +134,7 @@ impl<'de> Deserialize<'de> for MValue { A: SeqAccess<'de>, { let de = serde::de::value::SeqAccessDeserializer::new(data); - let sequence = Sequence::deserialize(de)?; + let sequence = MSequence::deserialize(de)?; Ok(MValue::Sequence(sequence)) } @@ -156,14 +154,61 @@ impl<'de> Deserialize<'de> for MValue { impl PartialEq for MValue { fn eq(&self, other: &Self) -> bool { + // match (self, other) { + // (Self::Bool(l0), Self::Bool(r0)) => l0 == r0, + // (Self::Number(l0), Self::Number(r0)) => l0 == r0, + // (Self::String(l0), Self::String(r0)) => l0 == r0, + // (Self::Sequence(l0), Self::Sequence(r0)) => l0 == r0, + // (Self::Mapping(l0), Self::Mapping(r0)) => l0 == r0, + // (Self::Matcher(l0), other) => l0.is_match(&other), + // (Self::Null, _) => true, + // _ => false, + // } + match (self, other) { - (Self::Bool(l0), Self::Bool(r0)) => l0 == r0, - (Self::Number(l0), Self::Number(r0)) => l0 == r0, - (Self::String(l0), Self::String(r0)) => l0 == r0, - (Self::Sequence(l0), Self::Sequence(r0)) => l0 == r0, - (Self::Mapping(l0), Self::Mapping(r0)) => l0 == r0, - (Self::Matcher(l0), other) => l0.is_match(&other), - (Self::Null, _) => true, + (MValue::Bool(left), MValue::Bool(right)) => { + let result = left.eq(right); + if !result { + progress_println!("Assertion failed at {} == {}", &left, &right); + } + result + } + (MValue::String(left), MValue::String(right)) => { + let result = left.eq(right); + if !result { + progress_println!("Assertion failed at {} == {}", &left, &right); + } + result + } + (MValue::Number(left), MValue::Number(right)) => { + let result = left.eq(right); + if !result { + progress_println!("Assertion failed at {} == {}", &left, &right); + } + result + } + (MValue::Sequence(left), MValue::Sequence(right)) => { + let result = left.eq(right); + if !result { + progress_println!("Assertion failed at {} == {}", &left, &right); + } + result + } + (MValue::Mapping(left), MValue::Mapping(right)) => { + let result = left.eq(right); + if !result { + progress_println!("Assertion failed at {} == {}", &left, &right); + } + result + } + (MValue::Matcher(left), right) => { + let result = left.is_match(&right); + if !result { + progress_println!("Assertion failed at {} == {}", &left, &right); + } + result + } + (MValue::Null, _) => true, _ => false, } } @@ -189,7 +234,7 @@ impl SuiteVariables for MValue { *s = new_s; } MValue::Sequence(seq) => { - for value in seq { + for value in seq.iter_mut() { value.populate_variables(variables)?; } } @@ -259,11 +304,11 @@ mod test { let mut mapping = Mapping::new(); mapping.insert( MValue::String("hello".to_string()), - MValue::Sequence(vec![ + MValue::Sequence(MSequence::from(vec![ MValue::Null, MValue::Bool(true), MValue::Number(1.into()), - ]), + ])), ); let mut nested_mapping = Mapping::new(); nested_mapping.insert( @@ -276,12 +321,12 @@ mod test { ); mapping.insert( MValue::String("world".to_string()), - MValue::Sequence(vec![ + MValue::Sequence(MSequence::from(vec![ MValue::Number(1.0.into()), MValue::String("string".to_string()), MValue::Bool(false), MValue::Mapping(nested_mapping), - ]), + ])), ); let expected = MValue::Mapping(mapping); @@ -305,11 +350,11 @@ mod test { let mut mapping = Mapping::new(); mapping.insert( MValue::String("hello".to_string()), - MValue::Sequence(vec![ + MValue::Sequence(MSequence::from(vec![ MValue::Matcher(Box::new(matcher)), MValue::Bool(true), MValue::Number(1.into()), - ]), + ])), ); let expected = MValue::Mapping(mapping); @@ -361,10 +406,10 @@ mod test { ), ( MValue::String("world".to_string()), - MValue::Sequence(vec![ + MValue::Sequence(MSequence::from(vec![ MValue::String("Say ${HELLO}!".to_string()), MValue::String("Say ${HELLO}!".to_string()), - ]), + ])), ), ])); value.populate_variables(&mut variables).unwrap(); @@ -377,10 +422,10 @@ mod test { ), ( MValue::String("world".to_string()), - MValue::Sequence(vec![ + MValue::Sequence(MSequence::from(vec![ MValue::String("Say hi!".to_string()), MValue::String("Say hi!".to_string()), - ]), + ])), ), ])) ); diff --git a/src/m_value/mod.rs b/src/m_value/mod.rs index 1b5a36a..1ba9ee4 100644 --- a/src/m_value/mod.rs +++ b/src/m_value/mod.rs @@ -1,4 +1,5 @@ pub mod m_map; +pub mod m_sequence; pub mod m_value; pub mod match_processor; pub mod matcher_definition; diff --git a/src/m_value/std_matchers/length.rs b/src/m_value/std_matchers/length.rs index 19698a9..09ed89d 100644 --- a/src/m_value/std_matchers/length.rs +++ b/src/m_value/std_matchers/length.rs @@ -130,13 +130,15 @@ impl From<&MValue> for LengthMatcher { #[cfg(test)] mod test { + use crate::m_value::m_sequence::MSequence; + use super::*; #[test] fn works_with_numeric_matcher() { let matcher = Length::new(); let args = MValue::Number(5.into()); - let value = MValue::Sequence(vec![MValue::Null; 5]); + let value = MValue::Sequence(MSequence::from(vec![MValue::Null; 5])); assert!(matcher.is_match(&args, &value)); } From 2cd247ea20f7a20eed938723095d21e8b2d2b439 Mon Sep 17 00:00:00 2001 From: Wvaviator Date: Fri, 16 Feb 2024 19:52:38 -0600 Subject: [PATCH 17/34] create mmatch trait with option support --- src/m_value/m_match.rs | 68 ++++++++++++++++++++++++++++++++++++++++++ src/m_value/mod.rs | 1 + 2 files changed, 69 insertions(+) create mode 100644 src/m_value/m_match.rs diff --git a/src/m_value/m_match.rs b/src/m_value/m_match.rs new file mode 100644 index 0000000..78c6fed --- /dev/null +++ b/src/m_value/m_match.rs @@ -0,0 +1,68 @@ +use std::fmt::Display; + +use colored::Colorize; + +use crate::progress_println; + +pub trait MMatch { + fn matches(&self, other: &T) -> bool; +} + +impl MMatch for Option +where + T: MMatch + Display, +{ + fn matches(&self, other: &Option) -> bool { + match (self, other) { + (Some(left), Some(right)) => left.matches(right), + (None, _) => true, + (Some(left), None) => { + progress_println!("Assertion failed at {} == {}", left, "None".red()); + false + } + } + } +} + +#[cfg(test)] +mod test { + use super::*; + + impl MMatch for &str { + fn matches(&self, other: &Self) -> bool { + self == other + } + } + + #[test] + fn matches_all_from_none() { + let from: Option<&str> = None; + let to = Some("hello"); + + assert!(from.matches(&to)); + } + + #[test] + fn matches_some_with_some() { + let from = Some("hello"); + let to = Some("hello"); + + assert!(from.matches(&to)); + } + + #[test] + fn match_fails_both_some() { + let from = Some("hello"); + let to = Some("world"); + + assert!(!from.matches(&to)); + } + + #[test] + fn match_fails_lhs_some_rhs_none() { + let from = Some("hello"); + let to: Option<&str> = None; + + assert!(!from.matches(&to)); + } +} diff --git a/src/m_value/mod.rs b/src/m_value/mod.rs index 1ba9ee4..18901f2 100644 --- a/src/m_value/mod.rs +++ b/src/m_value/mod.rs @@ -1,4 +1,5 @@ pub mod m_map; +pub mod m_match; pub mod m_sequence; pub mod m_value; pub mod match_processor; From 0d9bf4485327e49e3ef36a16f197e6ee8d17086d Mon Sep 17 00:00:00 2001 From: Wvaviator Date: Sat, 17 Feb 2024 08:14:39 -0600 Subject: [PATCH 18/34] add mmatch trait --- src/m_value/m_map.rs | 19 +++------ src/m_value/m_sequence.rs | 10 ++--- src/m_value/m_value.rs | 48 +++++++---------------- src/m_value/matcher_definition.rs | 20 +++++----- src/m_value/status_matcher.rs | 8 ++-- src/m_value/std_matchers/includes.rs | 4 +- src/suite/response/response_definition.rs | 8 ++-- src/suite/response/response_headers.rs | 10 ++--- 8 files changed, 51 insertions(+), 76 deletions(-) diff --git a/src/m_value/m_map.rs b/src/m_value/m_map.rs index dd22160..532cad5 100644 --- a/src/m_value/m_map.rs +++ b/src/m_value/m_map.rs @@ -14,9 +14,9 @@ use crate::{ variables::{variable_map::VariableMap, SuiteVariables}, }; -use super::m_value::MValue; +use super::{m_match::MMatch, m_value::MValue}; -#[derive(Debug, Default, Clone)] +#[derive(Debug, PartialEq, Default, Clone)] pub struct Mapping { map: IndexMap, } @@ -69,11 +69,11 @@ impl Serialize for Mapping { } } -impl PartialEq for Mapping { - fn eq(&self, other: &Self) -> bool { +impl MMatch for Mapping { + fn matches(&self, other: &Self) -> bool { for (k, v) in &self.map { let other_v = other.get(k).unwrap_or(&MValue::Null); - if v != other_v { + if !v.matches(other_v) { progress_println!( "Mismatch at key {}:\n expected: {}\n found: {}", &k, @@ -88,15 +88,6 @@ impl PartialEq for Mapping { } } -impl PartialEq for Option { - fn eq(&self, other: &Mapping) -> bool { - match self { - Some(mapping) => mapping.eq(other), - None => true, - } - } -} - impl FromIterator<(MValue, MValue)> for Mapping { fn from_iter>(iter: T) -> Self { let map = iter.into_iter().collect::>(); diff --git a/src/m_value/m_sequence.rs b/src/m_value/m_sequence.rs index d23b0e1..ed41cff 100644 --- a/src/m_value/m_sequence.rs +++ b/src/m_value/m_sequence.rs @@ -5,9 +5,9 @@ use std::{ use serde::Deserialize; -use super::m_value::MValue; +use super::{m_match::MMatch, m_value::MValue}; -#[derive(Debug, Default, Clone, Hash, Deserialize)] +#[derive(Debug, Default, Clone, Hash, PartialEq, Deserialize)] #[serde(transparent)] pub struct MSequence(Vec); @@ -24,9 +24,9 @@ impl DerefMut for MSequence { } } -impl PartialEq for MSequence { - fn eq(&self, other: &Self) -> bool { - self.0 == other.0 +impl MMatch for MSequence { + fn matches(&self, other: &Self) -> bool { + return self.0.iter().zip(other.0.iter()).all(|(a, b)| a.matches(b)); } } diff --git a/src/m_value/m_value.rs b/src/m_value/m_value.rs index 55424ce..e6e1c40 100644 --- a/src/m_value/m_value.rs +++ b/src/m_value/m_value.rs @@ -8,9 +8,11 @@ use serde_yaml::Number; use crate::{progress_println, variables::SuiteVariables}; -use super::{m_map::Mapping, m_sequence::MSequence, matcher_definition::MatcherDefintion}; +use super::{ + m_map::Mapping, m_match::MMatch, m_sequence::MSequence, matcher_definition::MatcherDefinition, +}; -#[derive(Debug, Hash, Clone)] +#[derive(Debug, PartialEq, Hash, Clone)] pub enum MValue { Null, Bool(bool), @@ -18,7 +20,7 @@ pub enum MValue { String(String), Sequence(MSequence), Mapping(Mapping), - Matcher(Box), + Matcher(Box), } impl Default for MValue { @@ -64,7 +66,7 @@ impl<'de> Deserialize<'de> for MValue { where E: de::Error, { - match MatcherDefintion::try_from(value) { + match MatcherDefinition::try_from(value) { Ok(matcher) => Ok(MValue::Matcher(Box::new(matcher))), Err(_) => Ok(MValue::String(String::from(value))), } @@ -74,7 +76,7 @@ impl<'de> Deserialize<'de> for MValue { where E: de::Error, { - match MatcherDefintion::try_from(value.as_str()) { + match MatcherDefinition::try_from(value.as_str()) { Ok(matcher) => Ok(MValue::Matcher(Box::new(matcher))), Err(_) => Ok(MValue::String(value)), } @@ -152,19 +154,8 @@ impl<'de> Deserialize<'de> for MValue { } } -impl PartialEq for MValue { - fn eq(&self, other: &Self) -> bool { - // match (self, other) { - // (Self::Bool(l0), Self::Bool(r0)) => l0 == r0, - // (Self::Number(l0), Self::Number(r0)) => l0 == r0, - // (Self::String(l0), Self::String(r0)) => l0 == r0, - // (Self::Sequence(l0), Self::Sequence(r0)) => l0 == r0, - // (Self::Mapping(l0), Self::Mapping(r0)) => l0 == r0, - // (Self::Matcher(l0), other) => l0.is_match(&other), - // (Self::Null, _) => true, - // _ => false, - // } - +impl MMatch for MValue { + fn matches(&self, other: &Self) -> bool { match (self, other) { (MValue::Bool(left), MValue::Bool(right)) => { let result = left.eq(right); @@ -188,21 +179,21 @@ impl PartialEq for MValue { result } (MValue::Sequence(left), MValue::Sequence(right)) => { - let result = left.eq(right); + let result = left.matches(right); if !result { progress_println!("Assertion failed at {} == {}", &left, &right); } result } (MValue::Mapping(left), MValue::Mapping(right)) => { - let result = left.eq(right); + let result = left.matches(right); if !result { progress_println!("Assertion failed at {} == {}", &left, &right); } result } (MValue::Matcher(left), right) => { - let result = left.is_match(&right); + let result = left.matches(&right); if !result { progress_println!("Assertion failed at {} == {}", &left, &right); } @@ -214,15 +205,6 @@ impl PartialEq for MValue { } } -impl PartialEq for Option { - fn eq(&self, other: &MValue) -> bool { - match self { - Some(value) => value == other, - None => true, - } - } -} - impl SuiteVariables for MValue { fn populate_variables( &mut self, @@ -345,7 +327,7 @@ mod test { - 1 "#; - let matcher = MatcherDefintion::try_from("$exists").unwrap(); + let matcher = MatcherDefinition::try_from("$exists").unwrap(); let mut mapping = Mapping::new(); mapping.insert( @@ -382,8 +364,8 @@ mod test { let yaml2 = serde_yaml::from_str::(yaml2).unwrap(); let yaml3 = serde_yaml::from_str::(yaml3).unwrap(); - assert_eq!(yaml1, yaml2); - assert_ne!(yaml1, yaml3); + assert!(yaml1.matches(&yaml2)); + assert!(!yaml1.matches(&yaml3)); } #[test] diff --git a/src/m_value/matcher_definition.rs b/src/m_value/matcher_definition.rs index 1e3f526..1162cd2 100644 --- a/src/m_value/matcher_definition.rs +++ b/src/m_value/matcher_definition.rs @@ -7,15 +7,15 @@ use crate::{ variables::{variable_map::VariableMap, SuiteVariables}, }; -use super::{m_value::MValue, matcher_map::MatcherMap}; +use super::{m_match::MMatch, m_value::MValue, matcher_map::MatcherMap}; #[derive(Debug, Clone, PartialEq, Hash)] -pub struct MatcherDefintion { +pub struct MatcherDefinition { match_key: String, args: MValue, } -impl Serialize for MatcherDefintion { +impl Serialize for MatcherDefinition { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, @@ -24,31 +24,31 @@ impl Serialize for MatcherDefintion { } } -impl MatcherDefintion { - pub fn is_match(&self, value: &MValue) -> bool { +impl MMatch for MatcherDefinition { + fn matches(&self, other: &MValue) -> bool { if let Some(matcher) = MatcherMap::get_matcher(&self.match_key) { - return matcher.is_match(&self.args, value); + return matcher.is_match(&self.args, other); } false } } -impl SuiteVariables for MatcherDefintion { +impl SuiteVariables for MatcherDefinition { fn populate_variables(&mut self, variables: &mut VariableMap) -> Result<(), CaptiError> { self.args.populate_variables(variables)?; Ok(()) } } -impl fmt::Display for MatcherDefintion { +impl fmt::Display for MatcherDefinition { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{} {}", self.match_key, self.args)?; Ok(()) } } -impl TryFrom<&str> for MatcherDefintion { +impl TryFrom<&str> for MatcherDefinition { type Error = (); fn try_from(value: &str) -> Result { @@ -57,7 +57,7 @@ impl TryFrom<&str> for MatcherDefintion { if let Some(_) = MatcherMap::get_matcher(key_candidate) { let args = parts.map(|s| s.into()).collect::>().join(" "); let args = serde_yaml::from_str::(&args).unwrap_or(MValue::Null); - return Ok(MatcherDefintion { + return Ok(MatcherDefinition { match_key: key_candidate.to_string(), args, }); diff --git a/src/m_value/status_matcher.rs b/src/m_value/status_matcher.rs index ae12b92..fb4155d 100644 --- a/src/m_value/status_matcher.rs +++ b/src/m_value/status_matcher.rs @@ -2,15 +2,17 @@ use std::fmt; use serde::Deserialize; -#[derive(Debug, Clone, Deserialize)] +use super::m_match::MMatch; + +#[derive(Debug, PartialEq, Clone, Deserialize)] #[serde(untagged)] pub enum StatusMatcher { Exact(u16), Class(String), } -impl PartialEq for StatusMatcher { - fn eq(&self, other: &Self) -> bool { +impl MMatch for StatusMatcher { + fn matches(&self, other: &Self) -> bool { match (self, other) { (StatusMatcher::Exact(a), StatusMatcher::Exact(b)) => a.eq(b), (StatusMatcher::Class(a), StatusMatcher::Class(b)) => a.eq(b), diff --git a/src/m_value/std_matchers/includes.rs b/src/m_value/std_matchers/includes.rs index a42577f..34cc0ff 100644 --- a/src/m_value/std_matchers/includes.rs +++ b/src/m_value/std_matchers/includes.rs @@ -1,4 +1,4 @@ -use crate::m_value::{m_value::MValue, match_processor::MatchProcessor}; +use crate::m_value::{m_match::MMatch, m_value::MValue, match_processor::MatchProcessor}; /// The $includes matcher checks an array to see if the provided value is included. /// Returns true if a matching (following standard matching rules) value is found in the array. @@ -18,7 +18,7 @@ impl MatchProcessor for Includes { fn is_match(&self, args: &MValue, value: &MValue) -> bool { match value { - MValue::Sequence(arr) => arr.iter().any(|i| args.eq(i)), + MValue::Sequence(arr) => arr.iter().any(|i| args.matches(i)), _ => false, } } diff --git a/src/suite/response/response_definition.rs b/src/suite/response/response_definition.rs index a8ee0f8..b879468 100644 --- a/src/suite/response/response_definition.rs +++ b/src/suite/response/response_definition.rs @@ -4,7 +4,7 @@ use serde::Deserialize; use crate::{ errors::CaptiError, - m_value::{m_value::MValue, status_matcher::StatusMatcher}, + m_value::{m_match::MMatch, m_value::MValue, status_matcher::StatusMatcher}, suite::test::TestResult, variables::{variable_map::VariableMap, SuiteVariables}, }; @@ -39,15 +39,15 @@ impl ResponseDefinition { } pub fn compare(&self, other: &ResponseDefinition) -> TestResult { - if !self.status.eq(&other.status) { + if !self.status.matches(&other.status) { return TestResult::fail("Status does not match."); } - if !self.headers.eq(&other.headers) { + if !self.headers.matches(&other.headers) { return TestResult::fail("Headers do not match."); } - if !self.body.eq(&other.body) { + if !self.body.matches(&other.body) { return TestResult::fail("Body does not match."); } diff --git a/src/suite/response/response_headers.rs b/src/suite/response/response_headers.rs index b5e2f09..e1184d4 100644 --- a/src/suite/response/response_headers.rs +++ b/src/suite/response/response_headers.rs @@ -1,13 +1,13 @@ use crate::{ errors::CaptiError, - m_value::{m_map::Mapping, m_value::MValue}, + m_value::{m_map::Mapping, m_match::MMatch, m_value::MValue}, variables::{variable_map::VariableMap, SuiteVariables}, }; use reqwest::header::HeaderMap; use serde::Deserialize; use std::{fmt, ops::Deref}; -#[derive(Debug, Default, Clone, Deserialize)] +#[derive(Debug, PartialEq, Default, Clone, Deserialize)] #[serde(transparent)] pub struct ResponseHeaders(Mapping); @@ -21,8 +21,8 @@ impl SuiteVariables for ResponseHeaders { } } -impl PartialEq for ResponseHeaders { - fn eq(&self, other: &Self) -> bool { +impl MMatch for ResponseHeaders { + fn matches(&self, other: &Self) -> bool { let lowercase_headers = self .0 .iter() @@ -35,7 +35,7 @@ impl PartialEq for ResponseHeaders { }) .collect::(); - lowercase_headers.eq(&other.0) + lowercase_headers.matches(&other.0) } } From 146ae9e8677438ac8fdfaf32414fbb06f9b66a8c Mon Sep 17 00:00:00 2001 From: Wvaviator Date: Sat, 17 Feb 2024 09:32:42 -0600 Subject: [PATCH 19/34] add context reporting for errors --- test_app/tests/recipe_create.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test_app/tests/recipe_create.yaml b/test_app/tests/recipe_create.yaml index 4beefb3..76d9651 100644 --- a/test_app/tests/recipe_create.yaml +++ b/test_app/tests/recipe_create.yaml @@ -80,7 +80,7 @@ tests: method: GET url: ${BASE_URL}/recipes/${RECIPE_ID} expect: - status: 2xx + status: 3xx body: id: ${RECIPE_ID} name: Guacamole @@ -91,7 +91,7 @@ tests: method: GET url: ${BASE_URL}/recipes expect: - status: 2xx + status: 202 body: '$includes { "id": "${RECIPE_ID}" }' - test: User only has one recipe From 8dc0927e918404bbc15d8125fd6972e348ee3b80 Mon Sep 17 00:00:00 2001 From: Wvaviator Date: Sat, 17 Feb 2024 09:32:55 -0600 Subject: [PATCH 20/34] add context reporting for errors --- src/lib.rs | 1 - src/m_value/m_map.rs | 18 +- src/m_value/m_match.rs | 69 +------- src/m_value/m_sequence.rs | 20 ++- src/m_value/m_value.rs | 68 ++++--- src/m_value/match_context.rs | 31 ++++ src/m_value/matcher_definition.rs | 19 +- src/m_value/mod.rs | 1 + src/m_value/status_matcher.rs | 17 +- src/matcher/match_cmp.rs | 207 ---------------------- src/matcher/match_result.rs | 126 ------------- src/matcher/matcher.rs | 176 ------------------ src/matcher/mod.rs | 9 - src/matcher/serde.rs | 59 ------ src/matcher/status_matcher.rs | 63 ------- src/matcher/values.rs | 30 ---- src/suite/response/response_definition.rs | 14 +- src/suite/response/response_headers.rs | 22 ++- src/suite/response/status.rs | 19 +- src/suite/test.rs | 21 ++- 20 files changed, 203 insertions(+), 787 deletions(-) create mode 100644 src/m_value/match_context.rs delete mode 100644 src/matcher/match_cmp.rs delete mode 100644 src/matcher/match_result.rs delete mode 100644 src/matcher/matcher.rs delete mode 100644 src/matcher/mod.rs delete mode 100644 src/matcher/serde.rs delete mode 100644 src/matcher/status_matcher.rs delete mode 100644 src/matcher/values.rs diff --git a/src/lib.rs b/src/lib.rs index a231bcf..9f8afa3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,7 +3,6 @@ pub mod client; pub mod errors; pub mod formatting; pub mod m_value; -pub mod matcher; pub mod progress; pub mod runner; pub mod suite; diff --git a/src/m_value/m_map.rs b/src/m_value/m_map.rs index 532cad5..8de1afe 100644 --- a/src/m_value/m_map.rs +++ b/src/m_value/m_map.rs @@ -14,7 +14,7 @@ use crate::{ variables::{variable_map::VariableMap, SuiteVariables}, }; -use super::{m_match::MMatch, m_value::MValue}; +use super::{m_match::MMatch, m_value::MValue, match_context::MatchContext}; #[derive(Debug, PartialEq, Default, Clone)] pub struct Mapping { @@ -86,6 +86,22 @@ impl MMatch for Mapping { true } + + fn get_context(&self, other: &Self) -> super::match_context::MatchContext { + let mut context = MatchContext::new(); + + for (k, v) in &self.map { + let other_v = other.get(k).unwrap_or(&MValue::Null); + if !v.matches(other_v) { + context += v.get_context(&other_v); + context.push(format!("Mismatch at key {}:", &k)); + context.push(format!(" expected: {}", &v)); + context.push(format!(" found: {}", &other_v)); + } + } + + context + } } impl FromIterator<(MValue, MValue)> for Mapping { diff --git a/src/m_value/m_match.rs b/src/m_value/m_match.rs index 78c6fed..b875ead 100644 --- a/src/m_value/m_match.rs +++ b/src/m_value/m_match.rs @@ -1,68 +1,15 @@ use std::fmt::Display; -use colored::Colorize; +use super::match_context::MatchContext; -use crate::progress_println; - -pub trait MMatch { - fn matches(&self, other: &T) -> bool; -} - -impl MMatch for Option +pub trait MMatch: Display where - T: MMatch + Display, + T: Display, { - fn matches(&self, other: &Option) -> bool { - match (self, other) { - (Some(left), Some(right)) => left.matches(right), - (None, _) => true, - (Some(left), None) => { - progress_println!("Assertion failed at {} == {}", left, "None".red()); - false - } - } - } -} - -#[cfg(test)] -mod test { - use super::*; - - impl MMatch for &str { - fn matches(&self, other: &Self) -> bool { - self == other - } - } - - #[test] - fn matches_all_from_none() { - let from: Option<&str> = None; - let to = Some("hello"); - - assert!(from.matches(&to)); - } - - #[test] - fn matches_some_with_some() { - let from = Some("hello"); - let to = Some("hello"); - - assert!(from.matches(&to)); - } - - #[test] - fn match_fails_both_some() { - let from = Some("hello"); - let to = Some("world"); - - assert!(!from.matches(&to)); - } - - #[test] - fn match_fails_lhs_some_rhs_none() { - let from = Some("hello"); - let to: Option<&str> = None; - - assert!(!from.matches(&to)); + fn matches(&self, other: &T) -> bool; + fn get_context(&self, other: &T) -> MatchContext { + let mut context = MatchContext::new(); + context.push(format!("Assertion failed at {} == {}", &self, &other)); + context } } diff --git a/src/m_value/m_sequence.rs b/src/m_value/m_sequence.rs index ed41cff..ad9a13d 100644 --- a/src/m_value/m_sequence.rs +++ b/src/m_value/m_sequence.rs @@ -5,7 +5,7 @@ use std::{ use serde::Deserialize; -use super::{m_match::MMatch, m_value::MValue}; +use super::{m_match::MMatch, m_value::MValue, match_context::MatchContext}; #[derive(Debug, Default, Clone, Hash, PartialEq, Deserialize)] #[serde(transparent)] @@ -28,6 +28,24 @@ impl MMatch for MSequence { fn matches(&self, other: &Self) -> bool { return self.0.iter().zip(other.0.iter()).all(|(a, b)| a.matches(b)); } + + fn get_context(&self, other: &Self) -> MatchContext { + let mut context = MatchContext::new(); + self.0 + .iter() + .zip(other.0.iter()) + .enumerate() + .for_each(|(i, (a, b))| { + if !a.matches(b) { + context += a.get_context(&b); + context.push(format!("Mismatch at sequence index {}:", i)); + context.push(format!(" expected: {}", a)); + context.push(format!(" found: {}", b)); + } + }); + + context + } } impl fmt::Display for MSequence { diff --git a/src/m_value/m_value.rs b/src/m_value/m_value.rs index e6e1c40..02d5eb9 100644 --- a/src/m_value/m_value.rs +++ b/src/m_value/m_value.rs @@ -6,10 +6,11 @@ use serde::{ }; use serde_yaml::Number; -use crate::{progress_println, variables::SuiteVariables}; +use crate::variables::SuiteVariables; use super::{ - m_map::Mapping, m_match::MMatch, m_sequence::MSequence, matcher_definition::MatcherDefinition, + m_map::Mapping, m_match::MMatch, m_sequence::MSequence, match_context::MatchContext, + matcher_definition::MatcherDefinition, }; #[derive(Debug, PartialEq, Hash, Clone)] @@ -156,52 +157,47 @@ impl<'de> Deserialize<'de> for MValue { impl MMatch for MValue { fn matches(&self, other: &Self) -> bool { + match (self, other) { + (MValue::Bool(left), MValue::Bool(right)) => left.eq(right), + (MValue::String(left), MValue::String(right)) => left.eq(right), + (MValue::Number(left), MValue::Number(right)) => left.eq(right), + (MValue::Sequence(left), MValue::Sequence(right)) => left.matches(right), + (MValue::Mapping(left), MValue::Mapping(right)) => left.matches(right), + (MValue::Matcher(left), right) => left.matches(&right), + (MValue::Null, _) => true, + _ => false, + } + } + + fn get_context(&self, other: &Self) -> super::match_context::MatchContext { match (self, other) { (MValue::Bool(left), MValue::Bool(right)) => { - let result = left.eq(right); - if !result { - progress_println!("Assertion failed at {} == {}", &left, &right); + if !left.eq(right) { + let mut context = MatchContext::new(); + context.push(format!("Assertion failed at {} == {}", &self, &other)); + return context; } - result } (MValue::String(left), MValue::String(right)) => { - let result = left.eq(right); - if !result { - progress_println!("Assertion failed at {} == {}", &left, &right); + if !left.eq(right) { + let mut context = MatchContext::new(); + context.push(format!("Assertion failed at {} == {}", &self, &other)); + return context; } - result } (MValue::Number(left), MValue::Number(right)) => { - let result = left.eq(right); - if !result { - progress_println!("Assertion failed at {} == {}", &left, &right); - } - result - } - (MValue::Sequence(left), MValue::Sequence(right)) => { - let result = left.matches(right); - if !result { - progress_println!("Assertion failed at {} == {}", &left, &right); - } - result - } - (MValue::Mapping(left), MValue::Mapping(right)) => { - let result = left.matches(right); - if !result { - progress_println!("Assertion failed at {} == {}", &left, &right); + if !left.eq(right) { + let mut context = MatchContext::new(); + context.push(format!("Assertion failed at {} == {}", &self, &other)); + return context; } - result } - (MValue::Matcher(left), right) => { - let result = left.matches(&right); - if !result { - progress_println!("Assertion failed at {} == {}", &left, &right); - } - result + (left, right) => { + return left.get_context(right); } - (MValue::Null, _) => true, - _ => false, } + + MatchContext::new() } } diff --git a/src/m_value/match_context.rs b/src/m_value/match_context.rs new file mode 100644 index 0000000..209ce60 --- /dev/null +++ b/src/m_value/match_context.rs @@ -0,0 +1,31 @@ +use std::{collections::VecDeque, fmt, ops::AddAssign}; + +#[derive(Debug, Clone, PartialEq)] +pub struct MatchContext(VecDeque); + +impl MatchContext { + pub fn new() -> Self { + MatchContext(VecDeque::new()) + } + + pub fn push(&mut self, context: impl Into) { + self.0.push_back(context.into()); + } +} + +impl AddAssign for MatchContext { + fn add_assign(&mut self, mut other: Self) { + while let Some(item) = other.0.pop_back() { + self.0.push_front(item); + } + } +} + +impl fmt::Display for MatchContext { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for context in &self.0 { + writeln!(f, " {}", context)?; + } + Ok(()) + } +} diff --git a/src/m_value/matcher_definition.rs b/src/m_value/matcher_definition.rs index 1162cd2..9420477 100644 --- a/src/m_value/matcher_definition.rs +++ b/src/m_value/matcher_definition.rs @@ -1,5 +1,6 @@ use std::fmt; +use colored::Colorize; use serde::Serialize; use crate::{ @@ -7,7 +8,9 @@ use crate::{ variables::{variable_map::VariableMap, SuiteVariables}, }; -use super::{m_match::MMatch, m_value::MValue, matcher_map::MatcherMap}; +use super::{ + m_match::MMatch, m_value::MValue, match_context::MatchContext, matcher_map::MatcherMap, +}; #[derive(Debug, Clone, PartialEq, Hash)] pub struct MatcherDefinition { @@ -32,6 +35,20 @@ impl MMatch for MatcherDefinition { false } + + fn get_context(&self, other: &MValue) -> MatchContext { + let mut context = MatchContext::new(); + if let Some(matcher) = MatcherMap::get_matcher(&self.match_key) { + if !matcher.is_match(&self.args, other) { + context.push(format!( + "Match failed at {} matches {}", + &self.to_string().yellow(), + &other.to_string().red() + )); + } + } + context + } } impl SuiteVariables for MatcherDefinition { diff --git a/src/m_value/mod.rs b/src/m_value/mod.rs index 18901f2..db847d0 100644 --- a/src/m_value/mod.rs +++ b/src/m_value/mod.rs @@ -2,6 +2,7 @@ pub mod m_map; pub mod m_match; pub mod m_sequence; pub mod m_value; +pub mod match_context; pub mod match_processor; pub mod matcher_definition; pub mod matcher_map; diff --git a/src/m_value/status_matcher.rs b/src/m_value/status_matcher.rs index fb4155d..dc6cbbf 100644 --- a/src/m_value/status_matcher.rs +++ b/src/m_value/status_matcher.rs @@ -1,8 +1,9 @@ use std::fmt; +use colored::Colorize; use serde::Deserialize; -use super::m_match::MMatch; +use super::{m_match::MMatch, match_context::MatchContext}; #[derive(Debug, PartialEq, Clone, Deserialize)] #[serde(untagged)] @@ -26,6 +27,18 @@ impl MMatch for StatusMatcher { _ => false, } } + + fn get_context(&self, other: &Self) -> MatchContext { + let mut context = MatchContext::new(); + if !self.matches(other) { + context.push(format!( + "Match failed at status {} matches {}", + &self.to_string().yellow(), + &other.to_string().red() + )); + } + context + } } impl From for StatusMatcher { @@ -38,7 +51,7 @@ impl fmt::Display for StatusMatcher { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { StatusMatcher::Exact(n) => { - writeln!(f, "Status: {}", n) + write!(f, "{}", n) } StatusMatcher::Class(s) => match s.as_str() { "2xx" => write!(f, "200-299"), diff --git a/src/matcher/match_cmp.rs b/src/matcher/match_cmp.rs deleted file mode 100644 index a596415..0000000 --- a/src/matcher/match_cmp.rs +++ /dev/null @@ -1,207 +0,0 @@ -use std::{collections::HashMap, fmt::Debug, hash::Hash}; - -use serde::Serialize; - -use super::{match_result::MatchResult, matcher::Matcher}; - -/// The MatchCmp trait implements match_cmp, which allows an object to be compared to another, -/// where the other object can have additional fields that are ignored. This is commonly referenced -/// as an "includes" comparison. -/// -/// All fields in A are required in B and must match, but fields absent from A that are present in B are ignored. -/// -/// Fields in A may also match fields in B if certain matcher conditions are met. -pub trait MatchCmp { - fn match_cmp(&self, other: &Self) -> MatchResult; -} - -impl MatchCmp for Option -where - T: MatchCmp + Debug, -{ - fn match_cmp(&self, other: &Self) -> MatchResult { - match (self, other) { - (Some(a), Some(b)) => return a.match_cmp(b), - (None, _) => return MatchResult::Matches, - (Some(a), None) => { - return MatchResult::Missing { - key: format!("{:#?}", a), - context: None, - } - } - } - } -} - -impl MatchCmp for String { - fn match_cmp(&self, other: &Self) -> MatchResult { - let string_value = serde_json::to_value(self.clone()); - - match string_value { - Ok(serde_json::Value::String(s)) - if Matcher::from(s.clone()) - .matches_value(&serde_json::Value::String(other.to_string())) => - { - MatchResult::Matches - } - _ => MatchResult::ValueMismatch { - expected: self.into(), - actual: other.into(), - context: None, - }, - } - } -} - -impl MatchCmp for HashMap -where - K: Eq + PartialEq + Hash + Debug, - V: MatchCmp + Serialize + Clone + Debug, -{ - fn match_cmp(&self, other: &Self) -> MatchResult { - for (key, value) in self { - match other.get(&key) { - Some(other_value) => match value.match_cmp(other_value) { - MatchResult::Matches => continue, - o => { - return o.with_context(format!("at compare ( {:#?}: {:#?} )", &key, &value)) - } - }, - _ => { - if let Ok(serde_json::Value::String(s)) = - serde_json::to_value::(value.clone()) - { - match s { - s if Matcher::from(s.clone()) - .matches_value(&serde_json::Value::Null) => - { - continue - } - _ => { - return MatchResult::Missing { - key: format!("{:#?}", &key), - context: Some(format!( - "at compare ( {:#?}: {:#?} )", - &key, &value - )), - } - } - } - } - return MatchResult::Missing { - key: format!("{:#?}", &key), - context: Some(format!("at compare ( {:#?}: {:#?} )", &key, &value)), - }; - } - } - } - - return MatchResult::Matches; - } -} - -impl MatchCmp for [T] -where - T: MatchCmp + Debug, -{ - fn match_cmp(&self, other: &Self) -> MatchResult { - let mut self_iter = self.iter().peekable(); - let mut other_iter = other.iter(); - while let Some(other_val) = other_iter.next() { - match self_iter.peek() { - Some(value) => { - if value.match_cmp(other_val) == MatchResult::Matches { - self_iter.next(); - } - } - None => return MatchResult::Matches, - } - } - return MatchResult::CollectionMismatch { - expected: format!("{:#?}", self), - actual: format!("{:#?}", other), - remaining: self_iter.count(), - context: None, - }; - } -} - -// The below code will work if specialization is stabilized. - -// impl MatchCmp for [T] -// where -// T: MatchCmp + Ord, -// { -// fn match_cmp(&self, other: &Self) -> bool { -// let self_sorted = self.clone(); -// self_sorted.sort(); -// let other_sorted = other.clone(); -// other_sorted.sort(); - -// let mut self_iter = self_sorted.iter().peekable(); -// let mut other_iter = other_sorted.iter(); -// while let Some(other_val) = other_iter.next() { -// match self_iter.peek() { -// Some(value) => { -// if value.match_cmp(other_val) { -// self_iter.next(); -// } -// } -// None => return true, -// } -// } -// return false; -// } -// } - -#[cfg(test)] -mod test { - use serde_json::json; - - use super::*; - - #[test] - fn json_value_includes() { - let json1 = json!({ - "string": "abc", - "number": 123, - "object": { - "key": "value" - } - }); - let json2 = json!({ - "string": "abc", - "number": 123, - "extra": "extra", - "object": { - "key": "value", - "extra": [ - "extra" - ] - } - }); - - assert!(json1.match_cmp(&json2) == MatchResult::Matches); - } - - #[test] - fn json_value_includes_array() { - let json1 = json!([1, 2, 3]); - let json2 = json!([1, 2, 3, 4]); - assert!(json1.match_cmp(&json2) == MatchResult::Matches); - } - - #[test] - fn hashmap_includes() { - let mut map1 = HashMap::new(); - map1.insert(String::from("key1"), String::from("value1")); - map1.insert(String::from("key2"), String::from("value2")); - - let mut map2 = HashMap::new(); - map2.insert(String::from("key1"), String::from("value1")); - map2.insert(String::from("key2"), String::from("value2")); - map2.insert(String::from("key3"), String::from("value3")); - - assert!(map1.match_cmp(&map2) == MatchResult::Matches); - } -} diff --git a/src/matcher/match_result.rs b/src/matcher/match_result.rs deleted file mode 100644 index 9a49afb..0000000 --- a/src/matcher/match_result.rs +++ /dev/null @@ -1,126 +0,0 @@ -use std::fmt; - -use colored::Colorize; - -#[derive(Debug, Clone, Eq, PartialEq)] -pub enum MatchResult { - Matches, - ValueMismatch { - expected: String, - actual: String, - context: Option, - }, - Missing { - key: String, - context: Option, - }, - CollectionMismatch { - expected: String, - actual: String, - remaining: usize, - context: Option, - }, -} - -impl MatchResult { - pub fn with_context(self, ctx: impl Into) -> Self { - match self { - MatchResult::Matches => self, - MatchResult::ValueMismatch { - expected, - actual, - context, - } => { - let context = match context { - Some(context) => context, - None => ctx.into(), - }; - MatchResult::ValueMismatch { - expected, - actual, - context: Some(context), - } - } - MatchResult::Missing { key, context } => { - let context = match context { - Some(context) => context, - None => ctx.into(), - }; - MatchResult::Missing { - key, - context: Some(context), - } - } - MatchResult::CollectionMismatch { - expected, - actual, - remaining, - context, - } => { - let context = match context { - Some(context) => context, - None => ctx.into(), - }; - MatchResult::CollectionMismatch { - expected, - actual, - remaining, - context: Some(context), - } - } - } - } -} - -impl fmt::Display for MatchResult { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - Ok(match self { - MatchResult::Matches => { - write!(f, "")?; - } - MatchResult::ValueMismatch { - expected, - actual, - context, - } => { - writeln!(f, "Match failed at assertion:")?; - writeln!( - f, - " {} {} == {} {}", - "[".red(), - expected, - actual, - "]".red() - )?; - if let Some(context) = context { - writeln!(f, " {}", context)?; - } - } - MatchResult::Missing { key, context } => { - writeln!(f, "Match failed due to missing item.")?; - writeln!(f, " Expected {}, Found {}", key, "None".red())?; - if let Some(context) = context { - writeln!(f, " {}", context)?; - } - } - MatchResult::CollectionMismatch { - expected, - actual, - remaining, - context, - } => { - writeln!(f, "Array values mismatch:")?; - writeln!(f, " Expected items: [ {} ]", expected)?; - writeln!(f, " Found items: [ {} ]", actual.red())?; - writeln!( - f, - " Matching unavailable for remaining {} elements.", - remaining - )?; - if let Some(context) = context { - writeln!(f, " {}", context)?; - } - } - }) - } -} diff --git a/src/matcher/matcher.rs b/src/matcher/matcher.rs deleted file mode 100644 index 64d3ee4..0000000 --- a/src/matcher/matcher.rs +++ /dev/null @@ -1,176 +0,0 @@ -use colored::Colorize; -use regex::Regex; - -use crate::progress_println; - -use super::{MatchCmp, MatchResult}; - -pub enum Matcher { - Exact(String), - Exists, - Regex(Regex), - Includes(serde_json::Value), - Empty, - Absent, - Length(usize), -} - -impl Matcher { - pub fn matches_value(&self, value: &serde_json::Value) -> bool { - match self { - Matcher::Exists => value.ne(&serde_json::Value::Null), - Matcher::Exact(expected) => match value { - serde_json::Value::String(s) => expected.eq(s), - _ => false, - }, - Matcher::Regex(regex) => match value { - serde_json::Value::String(s) => regex.is_match(s), - _ => false, - }, - Matcher::Includes(expected) => match value { - serde_json::Value::Array(arr) => arr.iter().any(|v| match expected.match_cmp(&v) { - MatchResult::Matches => true, - _ => false, - }), - _ => false, - }, - Matcher::Empty => match value { - serde_json::Value::Array(arr) => arr.is_empty(), - serde_json::Value::Object(obj) => obj.is_empty(), - _ => false, - }, - Matcher::Absent => match value { - serde_json::Value::Null => true, - _ => false, - }, - Matcher::Length(length) => match value { - serde_json::Value::Array(arr) => arr.len().eq(length), - serde_json::Value::Object(obj) => obj.len().eq(length), - serde_json::Value::String(s) => s.len().eq(length), - _ => false, - }, - } - } -} - -impl From<&str> for Matcher { - fn from(value: &str) -> Self { - match value { - "$exists" => Matcher::Exists, - "$empty" => Matcher::Empty, - "$absent" => Matcher::Absent, - s if s.starts_with("$length") => { - let length = value[8..].parse::().unwrap_or_else(|_| { - progress_println!("{}: Invalid length matcher {}.", "Warning".yellow(), value); - 0 - }); - - Matcher::Length(length) - } - s if s.starts_with("$regex") => match extract_regex(s) { - Some(regex) => Matcher::Regex(regex), - None => Matcher::Exact(s.to_string()), - }, - s if s.starts_with("$includes") => { - let value = serde_json::from_str(&s[10..]); - match value { - Ok(value) => Matcher::Includes(value), - Err(_) => Matcher::Includes(serde_json::Value::String(s[10..].to_string())), - } - } - _ => Matcher::Exact(value.to_string()), - } - } -} - -impl From<&String> for Matcher { - fn from(value: &String) -> Self { - Matcher::from(value.as_str()) - } -} - -impl From for Matcher { - fn from(value: String) -> Self { - Matcher::from(value.as_str()) - } -} - -fn extract_regex(s: &str) -> Option { - // format: $regex{ /regex/ } - let mut regex_str = s - .chars() - .skip_while(|c| c.ne(&'/')) - .skip(1) - .collect::>(); - while let Some(c) = regex_str.pop() { - if c.eq(&'/') { - break; - } - } - let regex_statement = regex_str.into_iter().collect::(); - - Regex::new(®ex_statement).ok() -} - -#[cfg(test)] -mod test { - use serde_json::Number; - - use super::*; - - #[test] - fn exact_values() { - let matches = - Matcher::from("123").matches_value(&serde_json::Value::String(String::from("123"))); - - assert!(matches); - } - - #[test] - fn exists_matches() { - let matches = - Matcher::from("$exists").matches_value(&serde_json::Value::Number(Number::from(3))); - assert!(matches); - } - - #[test] - fn exists_nomatch_null() { - let matches = Matcher::from("$exists").matches_value(&serde_json::Value::Null); - assert!(!matches); - } - - #[test] - fn extract_regex_fn() { - let regex_str = "$regex /(\\{\\})/"; - let regex = extract_regex(regex_str).unwrap(); - assert_eq!(regex.to_string(), "(\\{\\})"); - } - - #[test] - fn matches_regex_expression() { - let regex_str = "$regex /.*[Hh]ello!.*/"; - let match_string = serde_json::Value::String(String::from("Hello! How are you?")); - - let matcher = Matcher::from(regex_str); - let matches = matcher.matches_value(&match_string); - - assert!(matches); - } - - #[test] - fn test_regex() { - let re = Regex::new(r".*[Hh]ello!.*").unwrap(); - let hay = "Hello! How are you?"; - assert!(re.is_match(hay)); - } - - #[test] - fn test_length() { - let length_str = "$length 3"; - let match_arr = serde_json::from_str("[1, 2, 3]").unwrap(); - let matcher = Matcher::from(length_str); - let matches = matcher.matches_value(&match_arr); - - assert!(matches); - } -} diff --git a/src/matcher/mod.rs b/src/matcher/mod.rs deleted file mode 100644 index 1696bf7..0000000 --- a/src/matcher/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -pub mod match_cmp; -pub mod match_result; -pub mod matcher; -pub mod serde; -pub mod status_matcher; -pub mod values; - -pub use match_cmp::MatchCmp; -pub use match_result::MatchResult; diff --git a/src/matcher/serde.rs b/src/matcher/serde.rs deleted file mode 100644 index f375884..0000000 --- a/src/matcher/serde.rs +++ /dev/null @@ -1,59 +0,0 @@ -use serde_json::Value; - -use super::{matcher::Matcher, MatchCmp, MatchResult}; - -impl MatchCmp for serde_json::Value { - fn match_cmp(&self, other: &Self) -> MatchResult { - match (self, &other) { - (Value::Object(map), Value::Object(other_map)) => map - .match_cmp(other_map) - .with_context(format!("at compare ( {:#?} : {:#?} )", self, other)), - (Value::Array(arr), Value::Array(other_arr)) => arr - .match_cmp(other_arr) - .with_context(format!("at compare ( {:#?} : {:#?} )", self, other)), - (Value::Null, _) => MatchResult::Matches, - (Value::Bool(b), Value::Bool(other_b)) if b == other_b => MatchResult::Matches, - (Value::Number(n), Value::Number(other_n)) if n == other_n => MatchResult::Matches, - (Value::String(s), other) if Matcher::from(s).matches_value(other) => { - MatchResult::Matches - } - _ => MatchResult::ValueMismatch { - expected: format!("{:#?}", self), - actual: format!("{:#?}", other), - context: None, - }, - } - } -} - -impl MatchCmp for serde_json::Map { - fn match_cmp(&self, other: &Self) -> MatchResult { - for (key, value) in self.iter() { - match other.get(key.as_str()) { - Some(other_value) => match value.match_cmp(other_value) { - MatchResult::Matches => continue, - o => { - return o - .with_context(format!("at compare ( {:#?}: {:#?} )", &key, &value)) - .with_context(format!("at compare ( {:#?} : {:#?} )", &self, &other)) - } - }, - _ => match value { - serde_json::Value::String(s) - if Matcher::from(s).matches_value(&serde_json::Value::Null) => - { - continue; - } - _ => { - return MatchResult::Missing { - key: format!("{:#?}", &key), - context: Some(format!("at compare ( {:#?}: {:#?} )", &key, &value)), - } - } - }, - } - } - - return MatchResult::Matches; - } -} diff --git a/src/matcher/status_matcher.rs b/src/matcher/status_matcher.rs deleted file mode 100644 index b4c0c4a..0000000 --- a/src/matcher/status_matcher.rs +++ /dev/null @@ -1,63 +0,0 @@ -use std::fmt; - -use serde::{Deserialize, Serialize}; - -use super::{MatchCmp, MatchResult}; - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(untagged)] -pub enum StatusMatcher { - Exact(u16), - Class(String), -} - -impl MatchCmp for StatusMatcher { - fn match_cmp(&self, other: &Self) -> super::MatchResult { - let other = match other { - StatusMatcher::Exact(n) => *n, - _ => { - return super::MatchResult::Missing { - key: "Exact".to_string(), - context: None, - } - } - }; - - match self { - StatusMatcher::Exact(expected) if expected.eq(&other) => MatchResult::Matches, - StatusMatcher::Class(matcher) - if match matcher.as_str() { - "2xx" => (200..300).contains(&other), - "3xx" => (300..400).contains(&other), - "4xx" => (400..500).contains(&other), - "5xx" => (500..600).contains(&other), - _ => false, - } => - { - MatchResult::Matches - } - _ => MatchResult::ValueMismatch { - expected: format!("{}", self), - actual: format!("{}", other), - context: None, - }, - } - } -} - -impl fmt::Display for StatusMatcher { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - StatusMatcher::Exact(n) => { - writeln!(f, "Status: {}", n) - } - StatusMatcher::Class(s) => match s.as_str() { - "2xx" => write!(f, "200-299"), - "3xx" => write!(f, "300-399"), - "4xx" => write!(f, "400-499"), - "5xx" => write!(f, "500-599"), - _ => write!(f, "Invalid status range"), - }, - } - } -} diff --git a/src/matcher/values.rs b/src/matcher/values.rs deleted file mode 100644 index 2259af9..0000000 --- a/src/matcher/values.rs +++ /dev/null @@ -1,30 +0,0 @@ -use crate::matcher::{MatchCmp, MatchResult}; - -macro_rules! impl_value_mismatch { - ($type:ty) => { - impl MatchCmp for $type { - fn match_cmp(&self, other: &Self) -> MatchResult { - if self == other { - MatchResult::Matches - } else { - MatchResult::ValueMismatch { - expected: self.to_string(), - actual: other.to_string(), - context: None, - } - } - } - } - }; -} - -impl_value_mismatch!(u8); -impl_value_mismatch!(u16); -impl_value_mismatch!(u32); -impl_value_mismatch!(u64); -impl_value_mismatch!(i8); -impl_value_mismatch!(i16); -impl_value_mismatch!(i32); -impl_value_mismatch!(i64); -impl_value_mismatch!(f32); -impl_value_mismatch!(f64); diff --git a/src/suite/response/response_definition.rs b/src/suite/response/response_definition.rs index b879468..5ca39ae 100644 --- a/src/suite/response/response_definition.rs +++ b/src/suite/response/response_definition.rs @@ -40,15 +40,21 @@ impl ResponseDefinition { pub fn compare(&self, other: &ResponseDefinition) -> TestResult { if !self.status.matches(&other.status) { - return TestResult::fail("Status does not match."); + return TestResult::fail( + "Status does not match.", + self.status.get_context(&other.status), + ); } if !self.headers.matches(&other.headers) { - return TestResult::fail("Headers do not match."); + return TestResult::fail( + "Headers do not match.", + self.headers.get_context(&other.headers), + ); } if !self.body.matches(&other.body) { - return TestResult::fail("Body does not match."); + return TestResult::fail("Body does not match.", self.body.get_context(&other.body)); } return TestResult::Passed; @@ -68,7 +74,7 @@ impl fmt::Display for ResponseDefinition { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { writeln!(f, " ")?; - writeln!(f, " {}", self.status)?; + writeln!(f, " Status: {}", self.status)?; writeln!(f, " {}", self.headers)?; diff --git a/src/suite/response/response_headers.rs b/src/suite/response/response_headers.rs index e1184d4..867565b 100644 --- a/src/suite/response/response_headers.rs +++ b/src/suite/response/response_headers.rs @@ -1,6 +1,6 @@ use crate::{ errors::CaptiError, - m_value::{m_map::Mapping, m_match::MMatch, m_value::MValue}, + m_value::{m_map::Mapping, m_match::MMatch, m_value::MValue, match_context::MatchContext}, variables::{variable_map::VariableMap, SuiteVariables}, }; use reqwest::header::HeaderMap; @@ -37,6 +37,26 @@ impl MMatch for ResponseHeaders { lowercase_headers.matches(&other.0) } + + fn get_context(&self, other: &Self) -> MatchContext { + let mut context = MatchContext::new(); + if !self.matches(other) { + context.push("Headers do not match.".to_string()); + let lowercase_headers = self + .0 + .iter() + .map(|(key, value)| { + let key = match key { + MValue::String(s) => MValue::String(s.to_lowercase()), + other => other.clone(), + }; + (key, value.clone()) + }) + .collect::(); + context += lowercase_headers.get_context(&other.0); + } + context + } } impl FromIterator<(MValue, MValue)> for ResponseHeaders { diff --git a/src/suite/response/status.rs b/src/suite/response/status.rs index 6a5c0fa..572c1e8 100644 --- a/src/suite/response/status.rs +++ b/src/suite/response/status.rs @@ -2,7 +2,7 @@ use std::{fmt, ops::Deref}; use serde::Deserialize; -use crate::m_value::status_matcher::StatusMatcher; +use crate::m_value::{m_match::MMatch, match_context::MatchContext, status_matcher::StatusMatcher}; #[derive(Debug, Clone, Deserialize)] #[serde(transparent)] @@ -14,6 +14,23 @@ impl Status { } } +impl MMatch for Status { + fn matches(&self, other: &Self) -> bool { + match (&self.0, &other.0) { + (Some(a), Some(b)) => a.matches(b), + (None, _) => true, + (_, None) => false, + } + } + fn get_context(&self, other: &Self) -> MatchContext { + match (&self.0, &other.0) { + (Some(a), Some(b)) => a.get_context(b), + (None, _) => MatchContext::new(), + (_, None) => MatchContext::new(), + } + } +} + impl Deref for Status { type Target = Option; fn deref(&self) -> &Self::Target { diff --git a/src/suite/test.rs b/src/suite/test.rs index 6da64e7..f8aa0ca 100644 --- a/src/suite/test.rs +++ b/src/suite/test.rs @@ -8,6 +8,7 @@ use crate::{ client::Client, errors::CaptiError, formatting::Heading, + m_value::match_context::MatchContext, progress::Spinner, progress_println, variables::{variable_map::VariableMap, SuiteVariables}, @@ -69,9 +70,10 @@ impl TestDefinition { let test_result = self.expect.compare(&response); let test_result = match (test_result, self.should_fail) { - (TestResult::Passed, true) => { - TestResult::Failed(FailureReport::new("Expected failure, but test passed.")) - } + (TestResult::Passed, true) => TestResult::Failed(FailureReport::new( + "Expected failure, but test passed.", + MatchContext::new(), + )), (TestResult::Failed(_), true) => TestResult::Passed, (result, _) => result, }; @@ -102,15 +104,15 @@ impl SuiteVariables for TestDefinition { } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub enum TestResult { Passed, Failed(FailureReport), } impl TestResult { - pub fn fail(message: impl Into) -> Self { - TestResult::Failed(FailureReport::new(message)) + pub fn fail(message: impl Into, context: MatchContext) -> Self { + TestResult::Failed(FailureReport::new(message, context)) } } @@ -129,15 +131,17 @@ impl Into for &TestResult { } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub struct FailureReport { message: String, + match_context: MatchContext, } impl FailureReport { - pub fn new(message: impl Into) -> Self { + pub fn new(message: impl Into, match_context: MatchContext) -> Self { FailureReport { message: message.into(), + match_context, } } } @@ -145,6 +149,7 @@ impl FailureReport { impl fmt::Display for FailureReport { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { writeln!(f, "{}", self.message)?; + writeln!(f, "{}", self.match_context)?; Ok(()) } From 604a2a8bf1e98a136db3ea148cf055ac5effd2aa Mon Sep 17 00:00:00 2001 From: Wvaviator Date: Sat, 17 Feb 2024 10:58:24 -0600 Subject: [PATCH 21/34] add formatting to test failures --- src/m_value/m_map.rs | 23 ++++++++++---------- src/m_value/m_match.rs | 6 +----- src/m_value/m_value.rs | 36 ++++++++++++++++++++----------- src/m_value/status_matcher.rs | 2 +- src/progress/spinner.rs | 2 -- src/suite/test.rs | 2 +- test_app/tests/recipe_create.yaml | 9 ++++---- 7 files changed, 43 insertions(+), 37 deletions(-) diff --git a/src/m_value/m_map.rs b/src/m_value/m_map.rs index 8de1afe..937b88f 100644 --- a/src/m_value/m_map.rs +++ b/src/m_value/m_map.rs @@ -10,7 +10,6 @@ use serde::{Deserialize, Deserializer, Serialize}; use crate::{ errors::CaptiError, - progress_println, variables::{variable_map::VariableMap, SuiteVariables}, }; @@ -47,11 +46,19 @@ impl SuiteVariables for Mapping { impl fmt::Display for Mapping { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{{ ")?; - for (key, value) in &self.map { - write!(f, "{}: {}, ", key, value)?; + // write!(f, "{{ ")?; + // for (key, value) in &self.map { + // write!(f, "{}: {}, ", key, value)?; + // } + // write!(f, "}}") + + writeln!(f, " ")?; + let pretty_json = serde_json::to_string_pretty(&self).unwrap_or("".to_string()); + for line in pretty_json.lines() { + writeln!(f, " {}", line)?; } - write!(f, "}}") + + Ok(()) } } @@ -74,12 +81,6 @@ impl MMatch for Mapping { for (k, v) in &self.map { let other_v = other.get(k).unwrap_or(&MValue::Null); if !v.matches(other_v) { - progress_println!( - "Mismatch at key {}:\n expected: {}\n found: {}", - &k, - &v, - &other_v - ); return false; } } diff --git a/src/m_value/m_match.rs b/src/m_value/m_match.rs index b875ead..ab15d29 100644 --- a/src/m_value/m_match.rs +++ b/src/m_value/m_match.rs @@ -7,9 +7,5 @@ where T: Display, { fn matches(&self, other: &T) -> bool; - fn get_context(&self, other: &T) -> MatchContext { - let mut context = MatchContext::new(); - context.push(format!("Assertion failed at {} == {}", &self, &other)); - context - } + fn get_context(&self, other: &T) -> MatchContext; } diff --git a/src/m_value/m_value.rs b/src/m_value/m_value.rs index 02d5eb9..286645f 100644 --- a/src/m_value/m_value.rs +++ b/src/m_value/m_value.rs @@ -1,5 +1,6 @@ use std::fmt; +use colored::Colorize; use serde::{ de::{self, MapAccess, SeqAccess, Visitor}, Deserialize, Deserializer, Serialize, @@ -174,27 +175,44 @@ impl MMatch for MValue { (MValue::Bool(left), MValue::Bool(right)) => { if !left.eq(right) { let mut context = MatchContext::new(); - context.push(format!("Assertion failed at {} == {}", &self, &other)); + context.push(format!( + "Assertion failed at {} == {}", + &self.to_string().yellow(), + &other.to_string().red() + )); return context; } } (MValue::String(left), MValue::String(right)) => { if !left.eq(right) { let mut context = MatchContext::new(); - context.push(format!("Assertion failed at {} == {}", &self, &other)); + context.push(format!( + "Assertion failed at {} == {}", + &self.to_string().yellow(), + &other.to_string().red() + )); return context; } } (MValue::Number(left), MValue::Number(right)) => { if !left.eq(right) { let mut context = MatchContext::new(); - context.push(format!("Assertion failed at {} == {}", &self, &other)); + context.push(format!( + "Assertion failed at {} == {}", + &self.to_string().yellow(), + &other.to_string().red() + )); return context; } } - (left, right) => { + (MValue::Sequence(left), MValue::Sequence(right)) => { return left.get_context(right); } + (MValue::Mapping(left), MValue::Mapping(right)) => { + return left.get_context(right); + } + (MValue::Matcher(left), right) => return left.get_context(right), + _ => {} } MatchContext::new() @@ -243,15 +261,7 @@ impl fmt::Display for MValue { write!(f, "]")?; } MValue::Mapping(m) => { - writeln!(f, "{{")?; - for (i, (key, value)) in m.iter().enumerate() { - if i > 0 { - writeln!(f, ", ")?; - } - write!(f, "{}: {}", key, value)?; - } - writeln!(f, " ")?; - write!(f, "}}")?; + write!(f, "{}", m)?; } MValue::Matcher(m) => write!(f, "{}", m)?, } diff --git a/src/m_value/status_matcher.rs b/src/m_value/status_matcher.rs index dc6cbbf..3d89717 100644 --- a/src/m_value/status_matcher.rs +++ b/src/m_value/status_matcher.rs @@ -32,7 +32,7 @@ impl MMatch for StatusMatcher { let mut context = MatchContext::new(); if !self.matches(other) { context.push(format!( - "Match failed at status {} matches {}", + "Mismatch at response status:\n expected: {}\n found: {}", &self.to_string().yellow(), &other.to_string().red() )); diff --git a/src/progress/spinner.rs b/src/progress/spinner.rs index 214f1ba..33d2a88 100644 --- a/src/progress/spinner.rs +++ b/src/progress/spinner.rs @@ -70,11 +70,9 @@ impl Spinner { Ok(TestResult::Passed) => {} Ok(TestResult::Failed(failure_report)) => { progress_println!("{}", failure_report); - progress_println!(" "); } Err(e) => { progress_println!("{}", e); - progress_println!(" "); } } } diff --git a/src/suite/test.rs b/src/suite/test.rs index f8aa0ca..384a4e9 100644 --- a/src/suite/test.rs +++ b/src/suite/test.rs @@ -140,7 +140,7 @@ pub struct FailureReport { impl FailureReport { pub fn new(message: impl Into, match_context: MatchContext) -> Self { FailureReport { - message: message.into(), + message: format!("{} {}", "→".red(), message.into()), match_context, } } diff --git a/test_app/tests/recipe_create.yaml b/test_app/tests/recipe_create.yaml index 76d9651..822e4b4 100644 --- a/test_app/tests/recipe_create.yaml +++ b/test_app/tests/recipe_create.yaml @@ -2,7 +2,7 @@ suite: "Create Recipe" description: "This suite involves creating a new recipe and fetching its information." variables: BASE_URL: http://localhost:3000 - USER_EMAIL: recipe@testuser.com + USER_EMAIL: recipe@tests.com USER_PASSWORD: hG98s4%%phG tests: @@ -80,7 +80,7 @@ tests: method: GET url: ${BASE_URL}/recipes/${RECIPE_ID} expect: - status: 3xx + status: 2xx body: id: ${RECIPE_ID} name: Guacamole @@ -91,8 +91,9 @@ tests: method: GET url: ${BASE_URL}/recipes expect: - status: 202 - body: '$includes { "id": "${RECIPE_ID}" }' + status: 2xx + body: [1, 2, 3] + # body: '$includes { "id": "${RECIPE_ID}" }' - test: User only has one recipe description: User should only show one recipe in their list From 89ab50835db3e2af7d31b39fb0bca5ec73a7d929 Mon Sep 17 00:00:00 2001 From: Wvaviator Date: Sat, 17 Feb 2024 11:45:52 -0600 Subject: [PATCH 22/34] add some docs and tests --- src/m_value/m_map.rs | 44 +++++++++++------------ src/m_value/m_match.rs | 2 ++ src/m_value/m_sequence.rs | 2 ++ src/m_value/m_value.rs | 19 +++++----- src/m_value/match_context.rs | 2 ++ src/m_value/match_processor.rs | 2 ++ src/m_value/matcher_definition.rs | 2 ++ src/m_value/matcher_map.rs | 3 ++ src/m_value/status_matcher.rs | 41 +++++++++++++++++++++ src/suite/response/response_definition.rs | 4 +-- src/suite/response/response_headers.rs | 15 ++++---- 11 files changed, 94 insertions(+), 42 deletions(-) diff --git a/src/m_value/m_map.rs b/src/m_value/m_map.rs index 937b88f..9828ca0 100644 --- a/src/m_value/m_map.rs +++ b/src/m_value/m_map.rs @@ -15,14 +15,16 @@ use crate::{ use super::{m_match::MMatch, m_value::MValue, match_context::MatchContext}; +/// A map of `MValue` keys to `MValue` values. Equivalent to a typical YAML mapping, with the +/// additional matcher type handled. #[derive(Debug, PartialEq, Default, Clone)] -pub struct Mapping { +pub struct MMap { map: IndexMap, } -impl Mapping { +impl MMap { pub fn new() -> Self { - Mapping { + MMap { map: IndexMap::new(), } } @@ -35,7 +37,7 @@ impl Mapping { } } -impl SuiteVariables for Mapping { +impl SuiteVariables for MMap { fn populate_variables(&mut self, variables: &mut VariableMap) -> Result<(), CaptiError> { for value in self.map.values_mut() { value.populate_variables(variables)?; @@ -44,14 +46,8 @@ impl SuiteVariables for Mapping { } } -impl fmt::Display for Mapping { +impl fmt::Display for MMap { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - // write!(f, "{{ ")?; - // for (key, value) in &self.map { - // write!(f, "{}: {}, ", key, value)?; - // } - // write!(f, "}}") - writeln!(f, " ")?; let pretty_json = serde_json::to_string_pretty(&self).unwrap_or("".to_string()); for line in pretty_json.lines() { @@ -62,7 +58,7 @@ impl fmt::Display for Mapping { } } -impl Serialize for Mapping { +impl Serialize for MMap { fn serialize(&self, serializer: S) -> Result where S: serde::ser::Serializer, @@ -76,7 +72,7 @@ impl Serialize for Mapping { } } -impl MMatch for Mapping { +impl MMatch for MMap { fn matches(&self, other: &Self) -> bool { for (k, v) in &self.map { let other_v = other.get(k).unwrap_or(&MValue::Null); @@ -105,14 +101,14 @@ impl MMatch for Mapping { } } -impl FromIterator<(MValue, MValue)> for Mapping { +impl FromIterator<(MValue, MValue)> for MMap { fn from_iter>(iter: T) -> Self { let map = iter.into_iter().collect::>(); - Mapping { map } + MMap { map } } } -impl Hash for Mapping { +impl Hash for MMap { fn hash(&self, state: &mut H) { let mut xor = 0; for (k, v) in &self.map { @@ -125,30 +121,30 @@ impl Hash for Mapping { } } -impl From> for Mapping { +impl From> for MMap { fn from(vec: Vec<(MValue, MValue)>) -> Self { let mut map = IndexMap::new(); for (k, v) in vec { map.insert(k, v); } - Mapping { map } + MMap { map } } } -impl Deref for Mapping { +impl Deref for MMap { type Target = IndexMap; fn deref(&self) -> &Self::Target { &self.map } } -impl DerefMut for Mapping { +impl DerefMut for MMap { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.map } } -impl<'de> Deserialize<'de> for Mapping { +impl<'de> Deserialize<'de> for MMap { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, @@ -156,7 +152,7 @@ impl<'de> Deserialize<'de> for Mapping { struct Visitor; impl<'de> serde::de::Visitor<'de> for Visitor { - type Value = Mapping; + type Value = MMap; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { formatter.write_str("a YAML mapping") @@ -167,7 +163,7 @@ impl<'de> Deserialize<'de> for Mapping { where E: serde::de::Error, { - Ok(Mapping::new()) + Ok(MMap::new()) } #[inline] @@ -175,7 +171,7 @@ impl<'de> Deserialize<'de> for Mapping { where A: serde::de::MapAccess<'de>, { - let mut mapping = Mapping::new(); + let mut mapping = MMap::new(); while let Some(key) = data.next_key()? { match mapping.entry(key) { diff --git a/src/m_value/m_match.rs b/src/m_value/m_match.rs index ab15d29..40e02aa 100644 --- a/src/m_value/m_match.rs +++ b/src/m_value/m_match.rs @@ -2,6 +2,8 @@ use std::fmt::Display; use super::match_context::MatchContext; +// The MMatch trait allows values to be compared using the 'matches' method and any context about +// the matching can be provided via the 'get_context' method. pub trait MMatch: Display where T: Display, diff --git a/src/m_value/m_sequence.rs b/src/m_value/m_sequence.rs index ad9a13d..0175d32 100644 --- a/src/m_value/m_sequence.rs +++ b/src/m_value/m_sequence.rs @@ -7,6 +7,8 @@ use serde::Deserialize; use super::{m_match::MMatch, m_value::MValue, match_context::MatchContext}; +/// A sequence of `MValue` items. Equivalent to a typical YAML sequence, with the additional +/// matcher handled. #[derive(Debug, Default, Clone, Hash, PartialEq, Deserialize)] #[serde(transparent)] pub struct MSequence(Vec); diff --git a/src/m_value/m_value.rs b/src/m_value/m_value.rs index 286645f..446edc4 100644 --- a/src/m_value/m_value.rs +++ b/src/m_value/m_value.rs @@ -10,10 +10,13 @@ use serde_yaml::Number; use crate::variables::SuiteVariables; use super::{ - m_map::Mapping, m_match::MMatch, m_sequence::MSequence, match_context::MatchContext, + m_map::MMap, m_match::MMatch, m_sequence::MSequence, match_context::MatchContext, matcher_definition::MatcherDefinition, }; +/// The MValue is very similar to a typical YAML value, however instead of handling tags, special +/// matchers are handled instead during deserialization. These would normally appear as strings in +/// YAML values, but are separated out based on the matcher's presence in the MatcherMap. #[derive(Debug, PartialEq, Hash, Clone)] pub enum MValue { Null, @@ -21,7 +24,7 @@ pub enum MValue { Number(Number), String(String), Sequence(MSequence), - Mapping(Mapping), + Mapping(MMap), Matcher(Box), } @@ -147,7 +150,7 @@ impl<'de> Deserialize<'de> for MValue { A: MapAccess<'de>, { let de = serde::de::value::MapAccessDeserializer::new(data); - let mapping = Mapping::deserialize(de)?; + let mapping = MMap::deserialize(de)?; Ok(MValue::Mapping(mapping)) } } @@ -289,7 +292,7 @@ mod test { abc: def "#; - let mut mapping = Mapping::new(); + let mut mapping = MMap::new(); mapping.insert( MValue::String("hello".to_string()), MValue::Sequence(MSequence::from(vec![ @@ -298,7 +301,7 @@ mod test { MValue::Number(1.into()), ])), ); - let mut nested_mapping = Mapping::new(); + let mut nested_mapping = MMap::new(); nested_mapping.insert( MValue::String("test".to_string()), MValue::String("true".to_string()), @@ -335,7 +338,7 @@ mod test { let matcher = MatcherDefinition::try_from("$exists").unwrap(); - let mut mapping = Mapping::new(); + let mut mapping = MMap::new(); mapping.insert( MValue::String("hello".to_string()), MValue::Sequence(MSequence::from(vec![ @@ -387,7 +390,7 @@ mod test { fn populates_variables_nested() { let mut variables = crate::variables::variable_map::VariableMap::new(); variables.insert("HELLO", "hi"); - let mut value = MValue::Mapping(Mapping::from(vec![ + let mut value = MValue::Mapping(MMap::from(vec![ ( MValue::String("hello".to_string()), MValue::String("Say ${HELLO}!".to_string()), @@ -403,7 +406,7 @@ mod test { value.populate_variables(&mut variables).unwrap(); assert_eq!( value, - MValue::Mapping(Mapping::from(vec![ + MValue::Mapping(MMap::from(vec![ ( MValue::String("hello".to_string()), MValue::String("Say hi!".to_string()), diff --git a/src/m_value/match_context.rs b/src/m_value/match_context.rs index 209ce60..57590ce 100644 --- a/src/m_value/match_context.rs +++ b/src/m_value/match_context.rs @@ -1,5 +1,7 @@ use std::{collections::VecDeque, fmt, ops::AddAssign}; +/// Provides context for a mismatch between two `MValue` instances. +/// Used to print information for the user. #[derive(Debug, Clone, PartialEq)] pub struct MatchContext(VecDeque); diff --git a/src/m_value/match_processor.rs b/src/m_value/match_processor.rs index ef5617b..72fa7a5 100644 --- a/src/m_value/match_processor.rs +++ b/src/m_value/match_processor.rs @@ -1,5 +1,7 @@ use super::m_value::MValue; +/// The MatchProcessor trait must be implemented by a struct to handle custom matching. Every +/// matcher is a MatchProcessor. pub trait MatchProcessor: Send + Sync { fn key(&self) -> String; fn is_match(&self, args: &MValue, value: &MValue) -> bool; diff --git a/src/m_value/matcher_definition.rs b/src/m_value/matcher_definition.rs index 9420477..08cbabe 100644 --- a/src/m_value/matcher_definition.rs +++ b/src/m_value/matcher_definition.rs @@ -12,6 +12,8 @@ use super::{ m_match::MMatch, m_value::MValue, match_context::MatchContext, matcher_map::MatcherMap, }; +/// A wrapper definition for where to find the MatchProcessor necessary to process the match. Built +/// during deserialization and handles processing matches. #[derive(Debug, Clone, PartialEq, Hash)] pub struct MatcherDefinition { match_key: String, diff --git a/src/m_value/matcher_map.rs b/src/m_value/matcher_map.rs index a03a2ef..33a62b1 100644 --- a/src/m_value/matcher_map.rs +++ b/src/m_value/matcher_map.rs @@ -8,6 +8,9 @@ lazy_static! { static ref MATCHER_MAP: MatcherMap = MatcherMap::initialize(); } +/// The MatcherMap provides key/value lookups for every MatchProcessor based on the matcher +/// functions defined. Every matcher must be registered here before the map is made available +/// statically. pub struct MatcherMap(HashMap>); impl MatcherMap { diff --git a/src/m_value/status_matcher.rs b/src/m_value/status_matcher.rs index 3d89717..be09a54 100644 --- a/src/m_value/status_matcher.rs +++ b/src/m_value/status_matcher.rs @@ -5,6 +5,8 @@ use serde::Deserialize; use super::{m_match::MMatch, match_context::MatchContext}; +/// A special matcher specifically for statuses only. Statuses have different matching rules than +/// MValues. #[derive(Debug, PartialEq, Clone, Deserialize)] #[serde(untagged)] pub enum StatusMatcher { @@ -63,3 +65,42 @@ impl fmt::Display for StatusMatcher { } } } + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn matches_exact() { + let matcher = StatusMatcher::Exact(200); + let other = StatusMatcher::Exact(200); + assert!(matcher.matches(&other)); + } + + #[test] + fn match_fails_exact() { + let matcher = StatusMatcher::Exact(200); + let other = StatusMatcher::Exact(201); + assert!(!matcher.matches(&other)); + } + + #[test] + fn matches_range() { + let matcher = StatusMatcher::Class("2xx".to_string()); + let other = StatusMatcher::Exact(200); + let other2 = StatusMatcher::Exact(250); + let other3 = StatusMatcher::Exact(299); + assert!(matcher.matches(&other)); + assert!(matcher.matches(&other2)); + assert!(matcher.matches(&other3)); + } + + #[test] + fn match_fails_range() { + let matcher = StatusMatcher::Class("2xx".to_string()); + let other = StatusMatcher::Exact(300); + let other2 = StatusMatcher::Exact(199); + assert!(!matcher.matches(&other)); + assert!(!matcher.matches(&other2)); + } +} diff --git a/src/suite/response/response_definition.rs b/src/suite/response/response_definition.rs index 5ca39ae..a4d4b2d 100644 --- a/src/suite/response/response_definition.rs +++ b/src/suite/response/response_definition.rs @@ -74,9 +74,9 @@ impl fmt::Display for ResponseDefinition { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { writeln!(f, " ")?; - writeln!(f, " Status: {}", self.status)?; + writeln!(f, " Status: {}\n ", self.status)?; - writeln!(f, " {}", self.headers)?; + writeln!(f, " Headers:\n{}", self.headers)?; if let Ok(json) = serde_json::to_string_pretty(&self.body) { writeln!(f, " Body:")?; diff --git a/src/suite/response/response_headers.rs b/src/suite/response/response_headers.rs index 867565b..715bdc2 100644 --- a/src/suite/response/response_headers.rs +++ b/src/suite/response/response_headers.rs @@ -1,6 +1,6 @@ use crate::{ errors::CaptiError, - m_value::{m_map::Mapping, m_match::MMatch, m_value::MValue, match_context::MatchContext}, + m_value::{m_map::MMap, m_match::MMatch, m_value::MValue, match_context::MatchContext}, variables::{variable_map::VariableMap, SuiteVariables}, }; use reqwest::header::HeaderMap; @@ -9,7 +9,7 @@ use std::{fmt, ops::Deref}; #[derive(Debug, PartialEq, Default, Clone, Deserialize)] #[serde(transparent)] -pub struct ResponseHeaders(Mapping); +pub struct ResponseHeaders(MMap); impl SuiteVariables for ResponseHeaders { fn populate_variables(&mut self, variables: &mut VariableMap) -> Result<(), CaptiError> { @@ -33,7 +33,7 @@ impl MMatch for ResponseHeaders { }; (key, value.clone()) }) - .collect::(); + .collect::(); lowercase_headers.matches(&other.0) } @@ -52,7 +52,7 @@ impl MMatch for ResponseHeaders { }; (key, value.clone()) }) - .collect::(); + .collect::(); context += lowercase_headers.get_context(&other.0); } context @@ -61,7 +61,7 @@ impl MMatch for ResponseHeaders { impl FromIterator<(MValue, MValue)> for ResponseHeaders { fn from_iter>(iter: T) -> Self { - let map = iter.into_iter().collect::(); + let map = iter.into_iter().collect::(); ResponseHeaders(map) } } @@ -88,14 +88,14 @@ impl From<&HeaderMap> for ResponseHeaders { Some((header, value.to_string())) }) .map(|(key, value)| (MValue::String(key), MValue::String(value))) - .collect::(); + .collect::(); return ResponseHeaders(headers); } } impl Deref for ResponseHeaders { - type Target = Mapping; + type Target = MMap; fn deref(&self) -> &Self::Target { &self.0 } @@ -103,7 +103,6 @@ impl Deref for ResponseHeaders { impl fmt::Display for ResponseHeaders { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - writeln!(f, "Headers:")?; for (key, value) in self.0.iter() { writeln!(f, " ▹ {}: {}", key, value)?; } From 397ff83e1acc7039529c375fbc28052946cd9535 Mon Sep 17 00:00:00 2001 From: Wvaviator Date: Sat, 17 Feb 2024 14:33:50 -0600 Subject: [PATCH 23/34] add mvalue variables --- src/m_value/m_map.rs | 11 ++++ src/m_value/m_sequence.rs | 6 +++ src/m_value/m_value.rs | 40 ++++++++++++-- src/m_value/matcher_definition.rs | 6 +++ src/suite/request/request_definition.rs | 5 +- src/suite/request/request_headers.rs | 2 +- src/variables/mod.rs | 1 - src/variables/serde.rs | 27 ---------- src/variables/variable_map.rs | 69 ++++++++++++++++++++----- test_app/tests/recipe_create.yaml | 57 ++++++++++---------- test_app/tests/user_signup.yaml | 2 +- 11 files changed, 149 insertions(+), 77 deletions(-) delete mode 100644 src/variables/serde.rs diff --git a/src/m_value/m_map.rs b/src/m_value/m_map.rs index 9828ca0..f32d5c7 100644 --- a/src/m_value/m_map.rs +++ b/src/m_value/m_map.rs @@ -58,6 +58,17 @@ impl fmt::Display for MMap { } } +impl Into for MMap { + fn into(self) -> serde_json::Value { + serde_json::Value::Object( + self.map + .into_iter() + .map(|(k, v)| (k.to_string(), v.into())) + .collect(), + ) + } +} + impl Serialize for MMap { fn serialize(&self, serializer: S) -> Result where diff --git a/src/m_value/m_sequence.rs b/src/m_value/m_sequence.rs index 0175d32..d51f8c6 100644 --- a/src/m_value/m_sequence.rs +++ b/src/m_value/m_sequence.rs @@ -26,6 +26,12 @@ impl DerefMut for MSequence { } } +impl Into for MSequence { + fn into(self) -> serde_json::Value { + serde_json::Value::Array(self.0.into_iter().map(Into::into).collect()) + } +} + impl MMatch for MSequence { fn matches(&self, other: &Self) -> bool { return self.0.iter().zip(other.0.iter()).all(|(a, b)| a.matches(b)); diff --git a/src/m_value/m_value.rs b/src/m_value/m_value.rs index 446edc4..b98a601 100644 --- a/src/m_value/m_value.rs +++ b/src/m_value/m_value.rs @@ -228,9 +228,9 @@ impl SuiteVariables for MValue { variables: &mut crate::variables::variable_map::VariableMap, ) -> Result<(), crate::errors::CaptiError> { match self { - MValue::String(s) => { - let new_s = variables.replace_variables(&s)?; - *s = new_s; + MValue::String(_) => { + let new_s = variables.replace_variables(self.clone())?; + *self = new_s; } MValue::Sequence(seq) => { for value in seq.iter_mut() { @@ -273,6 +273,40 @@ impl fmt::Display for MValue { } } +impl Into for &str { + fn into(self) -> MValue { + self.to_string().into() + } +} + +impl Into for String { + fn into(self) -> MValue { + match MatcherDefinition::try_from(self.as_str()) { + Ok(matcher) => MValue::Matcher(Box::new(matcher)), + Err(_) => MValue::String(self), + } + } +} + +impl Into for MValue { + fn into(self) -> serde_json::Value { + match self { + MValue::Null => serde_json::Value::Null, + MValue::Bool(b) => serde_json::Value::Bool(b), + MValue::Number(n) => { + let n_json = serde_json::to_string(&n).unwrap_or("0".into()); + let n = + serde_json::to_value(&n_json).unwrap_or(serde_json::Value::Number(0.into())); + n + } + MValue::String(s) => serde_json::Value::String(s), + MValue::Sequence(arr) => arr.into(), + MValue::Mapping(m) => m.into(), + MValue::Matcher(m) => m.to_string().into(), + } + } +} + #[cfg(test)] mod test { use super::*; diff --git a/src/m_value/matcher_definition.rs b/src/m_value/matcher_definition.rs index 08cbabe..ef7dc3f 100644 --- a/src/m_value/matcher_definition.rs +++ b/src/m_value/matcher_definition.rs @@ -53,6 +53,12 @@ impl MMatch for MatcherDefinition { } } +impl Into for MatcherDefinition { + fn into(self) -> serde_json::Value { + serde_json::Value::String(self.to_string()) + } +} + impl SuiteVariables for MatcherDefinition { fn populate_variables(&mut self, variables: &mut VariableMap) -> Result<(), CaptiError> { self.args.populate_variables(variables)?; diff --git a/src/suite/request/request_definition.rs b/src/suite/request/request_definition.rs index cad18f6..d2c2339 100644 --- a/src/suite/request/request_definition.rs +++ b/src/suite/request/request_definition.rs @@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize}; use crate::{ errors::CaptiError, + m_value::m_value::MValue, variables::{variable_map::VariableMap, SuiteVariables}, }; @@ -13,7 +14,7 @@ pub struct RequestDefinition { pub method: RequestMethod, pub url: String, pub headers: Option, - pub body: Option, + pub body: Option, } impl RequestDefinition { @@ -44,7 +45,7 @@ impl RequestDefinition { impl SuiteVariables for RequestDefinition { fn populate_variables(&mut self, variables: &mut VariableMap) -> Result<(), CaptiError> { - self.url = variables.replace_variables(&self.url)?; + self.url = variables.replace_string_variables(&self.url)?; self.headers.populate_variables(variables)?; self.body.populate_variables(variables)?; diff --git a/src/suite/request/request_headers.rs b/src/suite/request/request_headers.rs index 8fc9736..484146c 100644 --- a/src/suite/request/request_headers.rs +++ b/src/suite/request/request_headers.rs @@ -15,7 +15,7 @@ pub struct RequestHeaders(HashMap); impl SuiteVariables for RequestHeaders { fn populate_variables(&mut self, variables: &mut VariableMap) -> Result<(), CaptiError> { for (_, value) in self.0.iter_mut() { - *value = variables.replace_variables(value.as_str())?; + *value = variables.replace_string_variables(value.as_str())?; } Ok(()) diff --git a/src/variables/mod.rs b/src/variables/mod.rs index 19af99f..c3aac67 100644 --- a/src/variables/mod.rs +++ b/src/variables/mod.rs @@ -1,4 +1,3 @@ -pub mod serde; pub mod variable_map; pub mod variables; diff --git a/src/variables/serde.rs b/src/variables/serde.rs deleted file mode 100644 index 0d1a361..0000000 --- a/src/variables/serde.rs +++ /dev/null @@ -1,27 +0,0 @@ -use crate::errors::CaptiError; - -use super::{variable_map::VariableMap, SuiteVariables}; - -impl SuiteVariables for serde_json::Value { - fn populate_variables(&mut self, variables: &mut VariableMap) -> Result<(), CaptiError> { - match self { - serde_json::Value::Object(map) => { - for (_, value) in map.iter_mut() { - value.populate_variables(variables)?; - } - } - serde_json::Value::Array(vec) => { - for value in vec.iter_mut() { - value.populate_variables(variables)?; - } - } - serde_json::Value::String(s) => { - let replaced = variables.replace_variables(s)?; - *self = serde_json::Value::String(replaced); - } - _ => {} - } - - Ok(()) - } -} diff --git a/src/variables/variable_map.rs b/src/variables/variable_map.rs index 6ff236d..614c694 100644 --- a/src/variables/variable_map.rs +++ b/src/variables/variable_map.rs @@ -4,30 +4,31 @@ use serde::Deserialize; use regex::{escape, Captures, Regex}; -use crate::{errors::CaptiError, progress_println}; +use crate::{errors::CaptiError, m_value::m_value::MValue, progress_println}; // Matches continuous ${words} wrapped like ${this} static VARIABLE_MATCHER: &str = r"\$\{(\w+)\}"; #[derive(Debug, Clone, PartialEq, Default, Deserialize)] -pub struct VariableMap(HashMap); +pub struct VariableMap(HashMap); impl VariableMap { pub fn new() -> Self { Self(HashMap::new()) } - pub fn insert(&mut self, key: impl Into, value: impl Into) { + pub fn insert(&mut self, key: impl Into, value: impl Into) { self.0.insert(key.into(), value.into()); } - pub fn get(&mut self, key: &str) -> Option { + pub fn get(&mut self, key: &str) -> Option { if let Some(value) = self.0.get(key) { return Some(value.clone()); } let env_result = std::env::var(key); if let Ok(env_value) = env_result { + let env_value = MValue::String(env_value); self.insert(key, env_value.clone()); Some(env_value) } else { @@ -37,9 +38,34 @@ impl VariableMap { // TODO: Load env variables from .env file } - pub fn replace_variables(&mut self, value: &str) -> Result { + pub fn replace_variables(&mut self, value: impl Into) -> Result { + match value.into() { + MValue::String(value) => { + let total_regex = Regex::new(&format!("^{}$", VARIABLE_MATCHER))?; + match total_regex.is_match(&value) { + true => self.replace_whole_value(&value), + false => self.replace_string_value(&value), + } + } + other => Ok(other), + } + } + + pub fn replace_string_variables(&mut self, value: &str) -> Result { let var_regex = Regex::new(VARIABLE_MATCHER)?; + let result = var_regex.replace_all(value, |captures: &Captures| { + if let Some(MValue::String(replacement_val)) = self.get(&captures[1]) { + replacement_val + } else { + captures[0].to_string() + } + }); + Ok(result.to_string()) + } + + fn replace_string_value(&mut self, value: &str) -> Result { + let var_regex = Regex::new(VARIABLE_MATCHER)?; let result = var_regex.replace_all(value, |captures: &Captures| { if let Some(replacement_val) = self.get(&captures[1]) { replacement_val.to_string() @@ -48,7 +74,22 @@ impl VariableMap { } }); - Ok(result.to_string()) + let result = MValue::String(result.to_string()); + + Ok(result) + } + + fn replace_whole_value(&mut self, value: &str) -> Result { + let var_regex = Regex::new(VARIABLE_MATCHER)?; + let result = match var_regex.captures(value) { + Some(captures) => match captures.get(1) { + Some(var_name) => self.get(var_name.as_str()).unwrap_or(MValue::Null), + None => MValue::Null, + }, + None => MValue::Null, + }; + + return Ok(result); } pub fn extract_variables(&mut self, extractor: &str, actual: &str) -> Result<(), CaptiError> { @@ -87,7 +128,7 @@ impl VariableMap { for name in full_regex.capture_names().flatten() { if let Some(value) = caps.name(name).map(|m| m.as_str().to_string()) { progress_println!("Extracted variable {}: {}", name, value); - self.insert(name.to_string(), value); + self.insert(name.to_string(), MValue::String(value)); } } } @@ -97,7 +138,7 @@ impl VariableMap { } impl Deref for VariableMap { - type Target = HashMap; + type Target = HashMap; fn deref(&self) -> &Self::Target { &self.0 } @@ -116,7 +157,7 @@ mod test { let value = "Say ${HELLO} to the ${WORLD}!"; let result = variables.replace_variables(value).unwrap(); - assert_eq!(result, "Say hi to the universe!"); + assert_eq!(result, MValue::String("Say hi to the universe!".into())); } #[test] @@ -128,7 +169,7 @@ mod test { variables.extract_variables(extractor, actual).unwrap(); - assert_eq!(variables["COLOR"], "brown"); + assert_eq!(variables["COLOR"], MValue::String("brown".into())); } #[test] @@ -140,8 +181,8 @@ mod test { variables.extract_variables(extractor, actual).unwrap(); - assert_eq!(variables["COLOR"], "brown"); - assert_eq!(variables["LAZY"], "lazy"); + assert_eq!(variables["COLOR"], MValue::String("brown".into())); + assert_eq!(variables["LAZY"], MValue::String("lazy".into())); } #[test] @@ -153,7 +194,7 @@ mod test { variables.extract_variables(extractor, actual).unwrap(); - assert_eq!(variables["ABC"], "333"); - assert_eq!(variables["DEF"], "1111"); + assert_eq!(variables["ABC"], MValue::String("333".into())); + assert_eq!(variables["DEF"], MValue::String("1111".into())); } } diff --git a/test_app/tests/recipe_create.yaml b/test_app/tests/recipe_create.yaml index 822e4b4..e95907d 100644 --- a/test_app/tests/recipe_create.yaml +++ b/test_app/tests/recipe_create.yaml @@ -4,6 +4,31 @@ variables: BASE_URL: http://localhost:3000 USER_EMAIL: recipe@tests.com USER_PASSWORD: hG98s4%%phG + USER_BODY_EXPECT: + id: $exists + email: ${USER_EMAIL} + displayName: john-smith + RECIPE: + name: Guacamole + description: > + A delicious classic guacamole recipe. + time: 10 + servings: 6 + ingredients: + - 3 avocados + - 1/2 red onion + - 1 lime + - 1 green bell pepper + - 1 jalapeno pepper + - 1/2 tsp cumin + - 1/2 tsp salt + - 1/2 tsp red pepper + instructions: [ + "Roughly chop onion, green pepper, and jalapeno and add to food processor. Pulse 2-3 times.", + "Cut and remove seeds from avocados, use spoon to scoop and mash in a bowl.", + "Mix in the vegetables, seasonings, and lime juice squeezed from a fresh lime." ] + + tests: - test: Create new user @@ -18,10 +43,7 @@ tests: password: ${USER_PASSWORD} expect: status: 2xx - body: - id: $exists - email: ${USER_EMAIL} - displayName: john-smith + body: ${USER_BODY_EXPECT} extract: body: id: ${USER_ID} @@ -45,25 +67,7 @@ tests: url: ${BASE_URL}/recipes headers: Content-Type: application/json - body: - name: Guacamole - description: > - A delicious classic guacamole recipe. - time: 10 - servings: 6 - ingredients: - - 3 avocados - - 1/2 red onion - - 1 lime - - 1 green bell pepper - - 1 jalapeno pepper - - 1/2 tsp cumin - - 1/2 tsp salt - - 1/2 tsp red pepper - instructions: [ - "Roughly chop onion, green pepper, and jalapeno and add to food processor. Pulse 2-3 times.", - "Cut and remove seeds from avocados, use spoon to scoop and mash in a bowl.", - "Mix in the vegetables, seasonings, and lime juice squeezed from a fresh lime." ] + body: ${RECIPE} expect: status: 2xx body: @@ -81,9 +85,7 @@ tests: url: ${BASE_URL}/recipes/${RECIPE_ID} expect: status: 2xx - body: - id: ${RECIPE_ID} - name: Guacamole + body: ${RECIPE} - test: Recipe in list description: Recipe should be visible when listing all recipes @@ -92,8 +94,7 @@ tests: url: ${BASE_URL}/recipes expect: status: 2xx - body: [1, 2, 3] - # body: '$includes { "id": "${RECIPE_ID}" }' + body: '$includes { "id": "${RECIPE_ID}" }' - test: User only has one recipe description: User should only show one recipe in their list diff --git a/test_app/tests/user_signup.yaml b/test_app/tests/user_signup.yaml index c39b089..69a87b9 100644 --- a/test_app/tests/user_signup.yaml +++ b/test_app/tests/user_signup.yaml @@ -2,7 +2,7 @@ suite: "User Signup" description: "Confirms that protected routes cannot be accessed until the user signs up." variables: BASE_URL: "http://localhost:3000" - USER_EMAIL: "testuser2@test.com" + USER_EMAIL: "testuser2@test2.com" USER_PASSWORD: "F7%df12UU9lk" tests: From 2ce4073d13de9b41c1d729445a926f34b6d5f4b1 Mon Sep 17 00:00:00 2001 From: Wvaviator Date: Sat, 17 Feb 2024 15:13:45 -0600 Subject: [PATCH 24/34] add support for nested variables --- src/variables/mod.rs | 1 + src/variables/var_regex.rs | 28 +++++++++++++++++++++++ src/variables/variable_map.rs | 37 ++++++++++++++++++++----------- test_app/tests/recipe_create.yaml | 3 ++- 4 files changed, 55 insertions(+), 14 deletions(-) create mode 100644 src/variables/var_regex.rs diff --git a/src/variables/mod.rs b/src/variables/mod.rs index c3aac67..4df7d73 100644 --- a/src/variables/mod.rs +++ b/src/variables/mod.rs @@ -1,3 +1,4 @@ +mod var_regex; pub mod variable_map; pub mod variables; diff --git a/src/variables/var_regex.rs b/src/variables/var_regex.rs new file mode 100644 index 0000000..63cdcce --- /dev/null +++ b/src/variables/var_regex.rs @@ -0,0 +1,28 @@ +use std::ops::Deref; + +use regex::Regex; + +// Matches continuous ${words} wrapped like ${this} +pub static VARIABLE_MATCHER: &str = r"\$\{(\w+)\}"; + +#[derive(Debug, Clone)] +pub struct VarRegex(Regex); + +impl Deref for VarRegex { + type Target = Regex; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl PartialEq for VarRegex { + fn eq(&self, other: &Self) -> bool { + self.as_str() == other.as_str() + } +} + +impl Default for VarRegex { + fn default() -> Self { + VarRegex(Regex::new(VARIABLE_MATCHER).expect("Invalid regex matcher.")) + } +} diff --git a/src/variables/variable_map.rs b/src/variables/variable_map.rs index 614c694..49eb34e 100644 --- a/src/variables/variable_map.rs +++ b/src/variables/variable_map.rs @@ -6,23 +6,33 @@ use regex::{escape, Captures, Regex}; use crate::{errors::CaptiError, m_value::m_value::MValue, progress_println}; -// Matches continuous ${words} wrapped like ${this} -static VARIABLE_MATCHER: &str = r"\$\{(\w+)\}"; +use super::{ + var_regex::{VarRegex, VARIABLE_MATCHER}, + SuiteVariables, +}; #[derive(Debug, Clone, PartialEq, Default, Deserialize)] -pub struct VariableMap(HashMap); +#[serde(transparent)] +pub struct VariableMap { + map: HashMap, + #[serde(skip)] + var_regex: VarRegex, +} impl VariableMap { pub fn new() -> Self { - Self(HashMap::new()) + VariableMap { + map: HashMap::new(), + var_regex: VarRegex::default(), + } } pub fn insert(&mut self, key: impl Into, value: impl Into) { - self.0.insert(key.into(), value.into()); + self.map.insert(key.into(), value.into()); } pub fn get(&mut self, key: &str) -> Option { - if let Some(value) = self.0.get(key) { + if let Some(value) = self.map.get(key) { return Some(value.clone()); } @@ -52,7 +62,7 @@ impl VariableMap { } pub fn replace_string_variables(&mut self, value: &str) -> Result { - let var_regex = Regex::new(VARIABLE_MATCHER)?; + let var_regex = self.var_regex.clone(); let result = var_regex.replace_all(value, |captures: &Captures| { if let Some(MValue::String(replacement_val)) = self.get(&captures[1]) { replacement_val @@ -65,7 +75,8 @@ impl VariableMap { } fn replace_string_value(&mut self, value: &str) -> Result { - let var_regex = Regex::new(VARIABLE_MATCHER)?; + let var_regex = self.var_regex.clone(); + let result = var_regex.replace_all(value, |captures: &Captures| { if let Some(replacement_val) = self.get(&captures[1]) { replacement_val.to_string() @@ -80,8 +91,7 @@ impl VariableMap { } fn replace_whole_value(&mut self, value: &str) -> Result { - let var_regex = Regex::new(VARIABLE_MATCHER)?; - let result = match var_regex.captures(value) { + let mut result = match self.var_regex.captures(value) { Some(captures) => match captures.get(1) { Some(var_name) => self.get(var_name.as_str()).unwrap_or(MValue::Null), None => MValue::Null, @@ -89,15 +99,16 @@ impl VariableMap { None => MValue::Null, }; + result.populate_variables(self)?; + return Ok(result); } pub fn extract_variables(&mut self, extractor: &str, actual: &str) -> Result<(), CaptiError> { - let var_regex = Regex::new(VARIABLE_MATCHER)?; let mut regex_pattern = String::from("^"); let mut last_end = 0; - for cap in var_regex.captures_iter(extractor) { + for cap in self.var_regex.captures_iter(extractor) { let start = match cap.get(0) { Some(start) => start.start(), None => continue, @@ -140,7 +151,7 @@ impl VariableMap { impl Deref for VariableMap { type Target = HashMap; fn deref(&self) -> &Self::Target { - &self.0 + &self.map } } diff --git a/test_app/tests/recipe_create.yaml b/test_app/tests/recipe_create.yaml index e95907d..062bee7 100644 --- a/test_app/tests/recipe_create.yaml +++ b/test_app/tests/recipe_create.yaml @@ -94,7 +94,8 @@ tests: url: ${BASE_URL}/recipes expect: status: 2xx - body: '$includes { "id": "${RECIPE_ID}" }' + # body: '$includes { "id": "${RECIPE_ID}" }' + body: '$includes ${RECIPE}' - test: User only has one recipe description: User should only show one recipe in their list From 153d1552f227552068826250a2efdbf932b41a14 Mon Sep 17 00:00:00 2001 From: Wvaviator Date: Sat, 17 Feb 2024 15:56:02 -0600 Subject: [PATCH 25/34] fix up nested variable structuring --- src/m_value/m_value.rs | 20 +++++ src/suite/request/request_definition.rs | 2 +- src/suite/request/request_headers.rs | 2 +- src/variables/variable_map.rs | 100 ++++++++++++++++++++---- test_app/tests/recipe_create.yaml | 8 +- test_app/tests/user_signup.yaml | 2 +- 6 files changed, 112 insertions(+), 22 deletions(-) diff --git a/src/m_value/m_value.rs b/src/m_value/m_value.rs index b98a601..b8c12bf 100644 --- a/src/m_value/m_value.rs +++ b/src/m_value/m_value.rs @@ -273,12 +273,32 @@ impl fmt::Display for MValue { } } +impl Into for MValue { + fn into(self) -> String { + match self { + MValue::Null => "null".to_string(), + MValue::Bool(b) => b.to_string(), + MValue::Number(n) => n.to_string(), + MValue::String(s) => s, + MValue::Sequence(arr) => arr.to_string(), + MValue::Mapping(m) => m.to_string(), + MValue::Matcher(m) => m.to_string(), + } + } +} + impl Into for &str { fn into(self) -> MValue { self.to_string().into() } } +impl Into for &String { + fn into(self) -> MValue { + self.to_string().into() + } +} + impl Into for String { fn into(self) -> MValue { match MatcherDefinition::try_from(self.as_str()) { diff --git a/src/suite/request/request_definition.rs b/src/suite/request/request_definition.rs index d2c2339..fec6c1e 100644 --- a/src/suite/request/request_definition.rs +++ b/src/suite/request/request_definition.rs @@ -45,7 +45,7 @@ impl RequestDefinition { impl SuiteVariables for RequestDefinition { fn populate_variables(&mut self, variables: &mut VariableMap) -> Result<(), CaptiError> { - self.url = variables.replace_string_variables(&self.url)?; + self.url = variables.replace_variables(&self.url)?.into(); self.headers.populate_variables(variables)?; self.body.populate_variables(variables)?; diff --git a/src/suite/request/request_headers.rs b/src/suite/request/request_headers.rs index 484146c..5b891f1 100644 --- a/src/suite/request/request_headers.rs +++ b/src/suite/request/request_headers.rs @@ -15,7 +15,7 @@ pub struct RequestHeaders(HashMap); impl SuiteVariables for RequestHeaders { fn populate_variables(&mut self, variables: &mut VariableMap) -> Result<(), CaptiError> { for (_, value) in self.0.iter_mut() { - *value = variables.replace_string_variables(value.as_str())?; + *value = variables.replace_variables(value.as_str())?.into(); } Ok(()) diff --git a/src/variables/variable_map.rs b/src/variables/variable_map.rs index 49eb34e..f642069 100644 --- a/src/variables/variable_map.rs +++ b/src/variables/variable_map.rs @@ -48,9 +48,28 @@ impl VariableMap { // TODO: Load env variables from .env file } + fn has_variables(&mut self, value: &str) -> bool { + let var_regex = self.var_regex.clone(); + + // Variables can still exist in the string despite them missing from the map + // This makes sure the variables not only exist but also exist in the map + var_regex.captures(value).is_some_and(|c| { + let var_name = match c.get(1) { + Some(name) => name.as_str(), + None => return false, + }; + + self.get(var_name).is_some() + }) + } + pub fn replace_variables(&mut self, value: impl Into) -> Result { match value.into() { MValue::String(value) => { + if !self.has_variables(&value) { + return Ok(MValue::String(value)); + } + let total_regex = Regex::new(&format!("^{}$", VARIABLE_MATCHER))?; match total_regex.is_match(&value) { true => self.replace_whole_value(&value), @@ -61,31 +80,40 @@ impl VariableMap { } } - pub fn replace_string_variables(&mut self, value: &str) -> Result { - let var_regex = self.var_regex.clone(); - let result = var_regex.replace_all(value, |captures: &Captures| { - if let Some(MValue::String(replacement_val)) = self.get(&captures[1]) { - replacement_val - } else { - captures[0].to_string() - } - }); + // pub fn replace_string_variables(&mut self, value: &str) -> Result { + // let var_regex = self.var_regex.clone(); - Ok(result.to_string()) - } + // let mut result = String::from(value); + + // while self.has_variables(&result) { + // result = var_regex + // .replace_all(value, |captures: &Captures| { + // if let Some(MValue::String(replacement_val)) = self.get(&captures[1]) { + // replacement_val + // } else { + // captures[0].to_string() + // } + // }) + // .to_string(); + // } + + // Ok(result) + // } fn replace_string_value(&mut self, value: &str) -> Result { let var_regex = self.var_regex.clone(); let result = var_regex.replace_all(value, |captures: &Captures| { if let Some(replacement_val) = self.get(&captures[1]) { - replacement_val.to_string() + replacement_val.into() } else { captures[0].to_string() } }); - let result = MValue::String(result.to_string()); + let mut result = MValue::String(result.to_string()); + + result.populate_variables(self)?; Ok(result) } @@ -99,7 +127,7 @@ impl VariableMap { None => MValue::Null, }; - result.populate_variables(self)?; + result.populate_variables(self); return Ok(result); } @@ -157,6 +185,8 @@ impl Deref for VariableMap { #[cfg(test)] mod test { + use serde_json::json; + use super::*; #[test] @@ -171,6 +201,48 @@ mod test { assert_eq!(result, MValue::String("Say hi to the universe!".into())); } + #[test] + fn replaces_nested_str_variables() { + let mut variables = VariableMap::new(); + variables.insert("HELLO", "hi ${WORLD}"); + variables.insert("WORLD", "universe"); + + let value = "Say ${HELLO}!"; + let result = variables.replace_variables(value).unwrap(); + + assert_eq!(result, MValue::String("Say hi universe!".into())); + } + + #[test] + fn replaces_whole_value() { + let mut variables = VariableMap::new(); + variables.insert("HELLO", MValue::Bool(true)); + + let value = "${HELLO}"; + let result = variables.replace_variables(value).unwrap(); + + assert_eq!(result, MValue::Bool(true)); + } + + #[test] + fn replaces_values_in_complex_nested_structure() { + let mut variables = VariableMap::new(); + + let json_str = r#"{ "hello": "${WORLD}" }"#; + let json_val = serde_json::from_str::(&json_str).unwrap(); + + variables.insert("WORLD", MValue::Bool(true)); + variables.insert("HELLO", json_val); + + let value = "${HELLO}"; + let result = variables.replace_variables(value).unwrap(); + + let expected_json_str = r#"{ "hello": true }"#; + let expected_json_val = serde_json::from_str::(&expected_json_str).unwrap(); + + assert_eq!(result, expected_json_val); + } + #[test] fn extracts_variable_from_str() { let mut variables = VariableMap::new(); diff --git a/test_app/tests/recipe_create.yaml b/test_app/tests/recipe_create.yaml index 062bee7..05229d9 100644 --- a/test_app/tests/recipe_create.yaml +++ b/test_app/tests/recipe_create.yaml @@ -2,7 +2,8 @@ suite: "Create Recipe" description: "This suite involves creating a new recipe and fetching its information." variables: BASE_URL: http://localhost:3000 - USER_EMAIL: recipe@tests.com + RECIPE_URL: ${BASE_URL}/recipes + USER_EMAIL: recipe1@tests.com USER_PASSWORD: hG98s4%%phG USER_BODY_EXPECT: id: $exists @@ -28,8 +29,6 @@ variables: "Cut and remove seeds from avocados, use spoon to scoop and mash in a bowl.", "Mix in the vegetables, seasonings, and lime juice squeezed from a fresh lime." ] - - tests: - test: Create new user request: @@ -64,7 +63,7 @@ tests: description: Create a new recipe request: method: POST - url: ${BASE_URL}/recipes + url: ${RECIPE_URL} headers: Content-Type: application/json body: ${RECIPE} @@ -94,7 +93,6 @@ tests: url: ${BASE_URL}/recipes expect: status: 2xx - # body: '$includes { "id": "${RECIPE_ID}" }' body: '$includes ${RECIPE}' - test: User only has one recipe diff --git a/test_app/tests/user_signup.yaml b/test_app/tests/user_signup.yaml index 69a87b9..12c67aa 100644 --- a/test_app/tests/user_signup.yaml +++ b/test_app/tests/user_signup.yaml @@ -2,7 +2,7 @@ suite: "User Signup" description: "Confirms that protected routes cannot be accessed until the user signs up." variables: BASE_URL: "http://localhost:3000" - USER_EMAIL: "testuser2@test2.com" + USER_EMAIL: "testuser3@test.com" USER_PASSWORD: "F7%df12UU9lk" tests: From 9cb82d862df1a084a669021544aac4210befa88e Mon Sep 17 00:00:00 2001 From: Wvaviator Date: Sat, 17 Feb 2024 17:09:42 -0600 Subject: [PATCH 26/34] create docs pages --- docs/.gitignore | 1 + docs/book.toml | 6 ++ docs/src/SUMMARY.md | 7 +++ docs/src/contributing.md | 1 + docs/src/installation.md | 106 +++++++++++++++++++++++++++++++++++ docs/src/introduction.md | 6 ++ docs/src/reporting_issues.md | 18 ++++++ 7 files changed, 145 insertions(+) create mode 100644 docs/.gitignore create mode 100644 docs/book.toml create mode 100644 docs/src/SUMMARY.md create mode 100644 docs/src/contributing.md create mode 100644 docs/src/installation.md create mode 100644 docs/src/introduction.md create mode 100644 docs/src/reporting_issues.md diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..7585238 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1 @@ +book diff --git a/docs/book.toml b/docs/book.toml new file mode 100644 index 0000000..a96185b --- /dev/null +++ b/docs/book.toml @@ -0,0 +1,6 @@ +[book] +authors = ["Alexander Durham (WVAviator)"] +language = "en" +multilingual = false +src = "src" +title = "Capti Documentation" diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md new file mode 100644 index 0000000..0fbd5c0 --- /dev/null +++ b/docs/src/SUMMARY.md @@ -0,0 +1,7 @@ +# Summary + +[Introduction](./introduction.md) +[Installation](./installation.md) + +- [Contributing](./contributing.md) + - [Reporting Issues](./reporting_issues.md) diff --git a/docs/src/contributing.md b/docs/src/contributing.md new file mode 100644 index 0000000..854139a --- /dev/null +++ b/docs/src/contributing.md @@ -0,0 +1 @@ +# Contributing diff --git a/docs/src/installation.md b/docs/src/installation.md new file mode 100644 index 0000000..511dec2 --- /dev/null +++ b/docs/src/installation.md @@ -0,0 +1,106 @@ +# Installation + +There are currently a few different ways you can install Capti. +- [Globally with NPM](#global-npm-install) +- [Locally with NPM for NodeJS Projects](#local-npm-install) +- [Anywhere else by downloading the binary executable](#binary-executable) + +## Global NPM Install + +Installing globally is the easiest way to get started with Capti if you already have Node/NPM installed. + +```bash +$ npm install -g capti +``` + +This will make Capti available for you to use in any project at the command line. To verify that your installation succeeded, you can run: + +```bash +$ capti --version +``` + +> Note: If your installation did not succeed, please [report the issue](reporting_issues.md#npm-installation) so that any necessary fixes can be made. + +## Local NPM Install + +Installing Capti locally in an NPM project is useful when you share your project with other developers and don't want them to have to globally install anything. It's also useful if you plan to use Capti for continuous integration in your project. + +To install locally in an NPM project, cd into your project directory and run: + +```bash +$ npm install --save-dev capti +``` + +> Note: If your installation did not succeed, please [report the issue](reporting_issues.md#npm-installation) so that any necessary fixes can be made. + +This will save Capti as a development dependency (meaning that it won't bundle into your final build). To use Capti in your project, first create a new folder in your project directory `tests/` (you can name it whatever you want). Then open your package.json and add the following script: + +```json +{ + "scripts": { + "test:capti": "capti --path ./tests" + } +} +``` + +The to run the tests your project, all you need to do is run: + +```bash +$ npm run test:capti +``` + +## Binary Executable + +If you want to install Capti on the command line but you do not want to use NPM or don't have Node installed, you can download the binary executable for your platform/architecture and manually add it to your `PATH`. + +To access the binary executable downloads, head over to the project's [GitHub Releases](https://github.com/WVAviator/capti/releases) and download the latest version. + +> Note: If you don't see your platform/architecture as a download option, and you think it should be available, feel free to put in an [enhancement issue](reporting_issues.md#enhancements) and we can look at possibly adding support for your architecture. + +Instructions for adding the binary to your PATH environment variable varies system-to-system. Please expand the section below for your platform. + +
+ +Unix (Linux / MacOS) + +1. Start by moving the downloaded binary to your `usr/local/bin` directory. + +```bash +$ mv capti /usr/local/bin/capti +``` + +2. Open your shell config file `.bash-profile` if you use bash, or `.zshrc` if you use zsh (default on MacOS) using your favorite text editor. + +3. Add the following line to the end of the file: + +``` +export PATH="/usr/local/bin/capti:$PATH" +``` + +4. Restart your shell, and you should be good to go. You can verify by running: + +```bash +$ capti --version +``` + +
+ + +
+ +Windows + +1. Move the binary to a convenient location. I recommend `C:/Program Files/Capti/capti.exe`. + +2. Right-click on the Start button > System > About > Advanced system settings > Environment Variables + +3. Edit the PATH Variable: + +- In the Environment Variables window, under "System variables" (for all users) or "User variables" (for the current user only), find and select the PATH variable, then click Edit. +- In the Edit Environment Variable window, click New and add the path to the folder that contains your binary. For example, C:\Program Files\MyBinary. +- Click OK to close each window. + +4. Restart any open command prompts or applications. + +
+ diff --git a/docs/src/introduction.md b/docs/src/introduction.md new file mode 100644 index 0000000..bf54127 --- /dev/null +++ b/docs/src/introduction.md @@ -0,0 +1,6 @@ + +# Introduction + +Capti is a lightweight end-to-end testing framework for testing REST APIs. You define your tests in YAML format as an API request and expected response. You can run your tests as often as you like, and even set them up to run in a cloud environment for Continuous Integration. + +This documentation serves as your guide for developing your end-to-end tests. Start by visiting the [Installation](chapter_2.md) pages to learn how you can set up Capti to run with your project. diff --git a/docs/src/reporting_issues.md b/docs/src/reporting_issues.md new file mode 100644 index 0000000..e50e952 --- /dev/null +++ b/docs/src/reporting_issues.md @@ -0,0 +1,18 @@ +# Reporting Issues + +Capti is not a very mature technology, and therefore issues are bound to occur. Taking the time to report them when they happen will help Capti to become a more stable and mature testing framework. + +## Issues + +To report a problem that you have had, please [open an issue](https://github.com/WVAviator/capti/issues/new) on GitHub. Please review the following types of issues and include as much of the requested information as possible. + +### Installation Issues + +#### NPM Installation + +If your NPM install did _not_ succeed, please [open an issue](https://github.com/WVAviator/capti/issues/new). Mention your CPU architecture, your platform (Windows, Linux, MacOS), and try to include the following: + +- CPU Architecture (x86, ARM, etc.) +- Platform (Linux, MacOS, Windows) +- Log file or files located in `node_modules/capti/logs` +- Any logs from NPM, if available From 8fcb2123f653a43d2bc9dd571c7dd51246b6a331 Mon Sep 17 00:00:00 2001 From: Wvaviator Date: Sat, 17 Feb 2024 21:16:09 -0600 Subject: [PATCH 27/34] add some more docs --- docs/src/SUMMARY.md | 7 + docs/src/getting_started.md | 180 ++++++++++++++++++++++ docs/src/matchers.md | 36 +++++ docs/src/writing_tests.md | 1 + src/m_value/m_value.rs | 8 +- src/suite/response/response_definition.rs | 14 +- 6 files changed, 243 insertions(+), 3 deletions(-) create mode 100644 docs/src/getting_started.md create mode 100644 docs/src/matchers.md create mode 100644 docs/src/writing_tests.md diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 0fbd5c0..1b6204d 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -3,5 +3,12 @@ [Introduction](./introduction.md) [Installation](./installation.md) +- [Getting Started](./getting_started.md) + - [Testing Basics](./writing_tests.md) + +- [Matchers](./matchers.md) + +---- + - [Contributing](./contributing.md) - [Reporting Issues](./reporting_issues.md) diff --git a/docs/src/getting_started.md b/docs/src/getting_started.md new file mode 100644 index 0000000..6712bfe --- /dev/null +++ b/docs/src/getting_started.md @@ -0,0 +1,180 @@ +# Getting Started + +## Prerequisites + +- A REST API project with at least one HTTP endpoint to test +- Capti installed in the project or globally on your machine. See the [installation instructions](installation.md). + +> Note: If you just want to see how Capti works and you have it installed globally, you can use a resource like [JSON Placeholder](https://jsonplaceholder.typicode.com/) and test fake HTTP endpoints. + +## Setting up + +For the examples in this setup, a NodeJS project will be referenced, but Capti is not limited to use in NodeJS projects. You can use it with Django, Ruby on Rails, Laravel, or any other framework for writing backends. + +The project in this example is a simple ts-node Express backend with one endpoint - /hello. This is the entire application: + +```js +// src/index.ts +import express from "express"; + +const app = express(); + +app.get("/hello", (req, res) => { + res.send("Hello World!"); +}); + +app.listen(3000, () => { + console.log("Server is running on port 3000"); +}); +``` + +1. Create a directory at the root of your project that will contain all your Capti tests. In this directory, create a new file `hello.yaml`. The directory structure will look something like this: + +``` +. +├── src/ +│ └── index.ts +├── tests/ +│ └── hello.yaml +├── .gitignore +├── package-lock.json +├── package.json +└── tsconfig.json +``` + +2. Add Capti to the project with `npm install --save-dev capti`, and then add the following script to `package.json`: + +```json +{ + "scripts": { + "test:capti": "capti --path ./tests" + } +} +``` + +> Note: If you are not using Node, you can skip this step and instead when you want to use Capti, just use the command `capti --path ./tests` instead of `npm run test:capti`. + +## Writing your first test + +1. Open `hello.yaml` in your favorite text editor. Start by adding the following fields: + +```yaml +suite: "Hello endpoint tests" +description: "Tests the various HTTP methods of the /hello endpoint." +``` + +- `suite` is the title of this test suite. Every file consists of a single test suite which groups together related tests. +- `description` is an optional field that describes the test suite in more detail. + +2. Next, add the `tests` array, which will consist of each of the tests we want to run. + +```yaml +suite: "Hello endpoint tests" +description: "Tests the various HTTP methods of the /hello endpoint." + +tests: + - test: "Get hello" +``` + +> Note: If you're unfamiliar with YAML syntax, take a moment to skim over the [YAML specification](https://yaml.org/spec/1.2.2/). While Capti does not use all of the advanced features of YAML, such as tags, knowing the basic syntax around arrays (sequences), objects (mappings), and primitive values can help you better understand what to expect when writing your tests. + +3. Let's write our first test. We need to add a `request` field first, which consists of the method, url, optional headers, and optional body of our HTTP request. + +```yaml +tests: + - test: "Get hello" + request: + method: GET + url: http://localhost:3000/hello +``` + +4. To finish the first test, we need to add the `expect` field, which contains the HTTP response we expect to get back. + +```yaml +tests: + - test: "Get hello" + request: + method: GET + url: http://localhost:3000/hello + expect: + status: 200 + body: + message: "Hello, world!" +``` + +We defined a status, which represents the [HTTP Status](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status) that we expect our server to return, and we defined a body with one property `message` whose value should exactly equal "Hello, world!". + +> Note: Most people are used to seeing response bodies in JSON format - writing them in YAML might take some getting used to. For reference, the above YAML syntax for `body` is equivalent to `{ "message": "Hello, world!" }` in JSON. + +## Running your first test suite + +Let's start up our server and try this out. Start your server with `npm start` (or whatever start command you usually use). To run our first test, we can run `npm run test:capti` (or if you installed globally, run `capti -p ./tests`). + +```bash +✗ [Hello endpoint tests] Get hello... [FAILED] +→ Body does not match. + Mismatched types + expected: + { + "message": "Hello, world!" + } + + found: "Hello world!" +``` + +Uh oh - we have screwed up. From the messages, we can see we were expecting a JSON object, but got a string instead. + +Here's the issue in our code: + +```js +// src/index.ts +app.get("/hello", (req, res) => { + res.send("Hello World!"); +}); +``` + +This is why we write tests. Let's update it to the correct format. + +```js +// src/index.ts +app.get("/hello", (req, res) => { + res.json({ message: "Hello World!" }); +}); +``` + +```bash +✗ [Hello endpoint tests] Get hello... [FAILED] → Body does not match. + Assertion failed at "Hello, world!" == "Hello World!" + Mismatch at key "message": + expected: "Hello, world!" + found: "Hello World!" +``` + +Still not quite right, but as you can see - the messages from Capti give us all the info we need to fix our endpoint. Clearly we can see that we are missing a comma and we have uppercased 'W'. Let's update the server one more time. + +```bash +== Hello endpoint tests ======= + +✓ Get hello + +Passed: 1 | Failed: 0 | Errors: 0 ▐ Total: 1 + +== Results Summary ======= + +Total Tests: 1 + +Total Passed: 1 +Total Failed: 0 +Total Errors: 0 +``` + +Now we have a passing test. The two summaries you see are one for the "Hello endpoint tests" test suite (there will be more once you add more test suites) which shows our test "Get hello" has passed. We also have the Results Summary, which shows the test results for all tests. + +## Conclusion + +Hopefully, with this quick guide, you can see where to go from here. Start writing more tests - for each of your endpoints. Take some time to learn more about how to write good tests with Capti, including: + +- How to use matchers to match values based on certain conditions +- How to write setup and teardown scripts and have Capti execute them for you +- How to use variables to write DRY tests +- How to extract variables from responses (such as JWT tokens) and carry them over to subsequent tests diff --git a/docs/src/matchers.md b/docs/src/matchers.md new file mode 100644 index 0000000..0e3eb16 --- /dev/null +++ b/docs/src/matchers.md @@ -0,0 +1,36 @@ +# Matchers + +Matchers are what make Capti so flexible when writing tests for HTTP endpoints. Often we don't know exactly what we're going to get back from the server (e.g. a unique ID value generated by the database). Using matchers, we can verify that the the response returns any value, or returns value to follows some sort of pattern or structure. + +Here is an example test that makes heavy use of various matchers: + +```yaml +tests: + - test: "get hello" + description: "hello endpoint responds with some type of greeting" + request: + method: GET + url: "http://localhost:3000/hello" + expect: + status: 2xx # match any 200 level status code + headers: + Content-Type: application/json # exact match + body: + message: $regex /[Hh]ello/ # match based on regex + currentTime: $exists # match anything as long as it is present +``` + +## Format + +Matchers are always a keyword prefixed with a `$` symbol, and followed by any number of arguments. The arguments, in some case, are also valid as a matcher themselves, thus some matchers can be nested. Here are some examples: + +``` +# this matcher takes no arguments +$exists + +# this matcher takes one argument - a regex string used to match the response +$regex /[Hh]ello/ + +# this matcher checks whether the array at this position contains an object with the property "id" +$includes { "id": $exists } +``` diff --git a/docs/src/writing_tests.md b/docs/src/writing_tests.md new file mode 100644 index 0000000..d9c63f5 --- /dev/null +++ b/docs/src/writing_tests.md @@ -0,0 +1 @@ +# Writing Tests diff --git a/src/m_value/m_value.rs b/src/m_value/m_value.rs index b8c12bf..8266306 100644 --- a/src/m_value/m_value.rs +++ b/src/m_value/m_value.rs @@ -215,7 +215,13 @@ impl MMatch for MValue { return left.get_context(right); } (MValue::Matcher(left), right) => return left.get_context(right), - _ => {} + (left, right) => { + let mut context = MatchContext::new(); + context.push(String::from("Mismatched types")); + context.push(format!(" expected: {}", &left.to_string(),)); + context.push(format!(" found: {}", &right.to_string(),)); + return context; + } } MatchContext::new() diff --git a/src/suite/response/response_definition.rs b/src/suite/response/response_definition.rs index a4d4b2d..932bea3 100644 --- a/src/suite/response/response_definition.rs +++ b/src/suite/response/response_definition.rs @@ -5,6 +5,7 @@ use serde::Deserialize; use crate::{ errors::CaptiError, m_value::{m_match::MMatch, m_value::MValue, status_matcher::StatusMatcher}, + progress_println, suite::test::TestResult, variables::{variable_map::VariableMap, SuiteVariables}, }; @@ -26,11 +27,20 @@ impl ResponseDefinition { let headers = ResponseHeaders::from(response.headers()); - let body = match response.json::().await { + let body_text = response.text().await.unwrap_or("".to_string()); + let body = match serde_json::from_str::(&body_text) { Ok(body) => body, - Err(_) => MValue::Null, + Err(_) => MValue::String(body_text), }; + // let body = match response.json::().await { + // Ok(body) => body, + // Err(e) => { + // let text_body = response.text().await.unwrap_or("".to_string()); + // MValue::String(text_body) + // } + // }; + ResponseDefinition { status, headers, From 6c11723cd6209ec7101e428b95be7524185626db Mon Sep 17 00:00:00 2001 From: Wvaviator Date: Sun, 18 Feb 2024 09:28:02 -0600 Subject: [PATCH 28/34] add matcher docs --- docs/src/SUMMARY.md | 6 +++ docs/src/matchers.md | 5 ++ docs/src/matchers/absent.md | 23 +++++++++ docs/src/matchers/empty.md | 23 +++++++++ docs/src/matchers/exists.md | 27 ++++++++++ docs/src/matchers/includes.md | 86 +++++++++++++++++++++++++++++++ docs/src/matchers/length.md | 34 ++++++++++++ docs/src/matchers/regex.md | 31 +++++++++++ docs/src/writing_tests.md | 55 ++++++++++++++++++++ src/m_value/matcher_definition.rs | 2 +- src/runner/runner.rs | 18 ++++++- src/variables/variable_map.rs | 4 +- test_app/tests/recipe_create.yaml | 6 ++- 13 files changed, 312 insertions(+), 8 deletions(-) create mode 100644 docs/src/matchers/absent.md create mode 100644 docs/src/matchers/empty.md create mode 100644 docs/src/matchers/exists.md create mode 100644 docs/src/matchers/includes.md create mode 100644 docs/src/matchers/length.md create mode 100644 docs/src/matchers/regex.md diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 1b6204d..ee795a3 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -7,6 +7,12 @@ - [Testing Basics](./writing_tests.md) - [Matchers](./matchers.md) + - [$exists](./matchers/exists.md) + - [$absent](./matchers/absent.md) + - [$regex](./matchers/regex.md) + - [$length](./matchers/length.md) + - [$empty](./matchers/empty.md) + - [$includes](./matchers/includes.md) ---- diff --git a/docs/src/matchers.md b/docs/src/matchers.md index 0e3eb16..adcf83e 100644 --- a/docs/src/matchers.md +++ b/docs/src/matchers.md @@ -33,4 +33,9 @@ $regex /[Hh]ello/ # this matcher checks whether the array at this position contains an object with the property "id" $includes { "id": $exists } + +# includes can also check any other valid YAML/JSON object, value, or matcher +$includes 5 +$includes "hello, world!" +$includes $includes 5 ``` diff --git a/docs/src/matchers/absent.md b/docs/src/matchers/absent.md new file mode 100644 index 0000000..c8f0ce8 --- /dev/null +++ b/docs/src/matchers/absent.md @@ -0,0 +1,23 @@ +# $absent + +The `$absent` matcher is the inverse of [`$exists`](./exists.md) - where `$exists` matches any non-null value, `$absent` only matches null or missing values. + +More specifically, it will match: + +- `null` +- Values where the key/value pair is completely missing + +## Example + +In the example below, we are calling an endpoint to get user information from the server. In our `expect` defintion, we assert that the `password` field should be null or missing, and we didn't accidentally include the password hash with the response after fetching user data from the database. + +```yaml +tests: + - test: "Get user info" + request: + method: GET + url: http://localhost:3000/user/${USER_ID} + expect: + email: test@test.com + password: $absent +``` diff --git a/docs/src/matchers/empty.md b/docs/src/matchers/empty.md new file mode 100644 index 0000000..655abab --- /dev/null +++ b/docs/src/matchers/empty.md @@ -0,0 +1,23 @@ +# $empty + +The `$empty` matcher checks that an object, array, or string has length 0. + +Because of Capti's [implicit matching](../writing_tests.md#implicit-matching), you cannot simply write something like this: + +```yaml + expect: + body: + comments: [] +``` + +The goal here is to assert that the comments array is empty, however because of implicit matching, this will match _any number_ of comments. + +To properly assert that there are no comments in this array, you can use the `$empty` matcher instead. + +```yaml + expect: + body: + comments: $empty +``` + +You can also use `$empty` to match empty objects or strings. Using `$empty` is identical to writing [`$length 0`](./length.md). diff --git a/docs/src/matchers/exists.md b/docs/src/matchers/exists.md new file mode 100644 index 0000000..ba473b1 --- /dev/null +++ b/docs/src/matchers/exists.md @@ -0,0 +1,27 @@ +# $exists + +The `$exists` matcher checks that an object or value exists in the response. + +More specifically, it will match everything except: + +- `null` +- Values where the key/value pair is completely missing + +## Example + +This is example is calling a POST endpoint to create a recipe. When the recipe is returned, it will have been given an `id` property by the database. We do not know what this `id` will be, and therefore we cannot assert that it should be any particular value. We do, however, want to make sure it exists in the response. This is a perfect use case for `$exists`. + +```yaml + - test: Create new recipe + description: Create a new recipe + request: + method: POST + url: http://localhost:3000/recipes + headers: + Content-Type: application/json + body: ${RECIPE} + expect: + status: 2xx + body: + id: $exists +``` diff --git a/docs/src/matchers/includes.md b/docs/src/matchers/includes.md new file mode 100644 index 0000000..ad098ac --- /dev/null +++ b/docs/src/matchers/includes.md @@ -0,0 +1,86 @@ +# $includes + +The `$includes` matcher is used to verify that a specific value exists in an array. + +## Usage + +``` +$includes +``` + +The `$includes` matcher takes one argument, which is a value or matcher that should be found in the array. It can be used to match primitive values: + +``` +$includes 5 +$includes "Hello, world!" +$includes true +``` + +It can be used to match objects or other arrays, following the same [implicit matching](../writing_tests.md#implicit-matching) rules used in normal `expect` definitions. However, because of limitations of YAML, these objects or arrays must be defined as JSON wrapped in quotes, or separately as [variables](../variables.md). + +``` +$includes "{ "id": "1A2B3C" }" +$includes "[1, 2, 3]" +``` + +It can also be used to match other matchers. For example, checking if a string that matches a regex using the [`$regex`](./regex.md) matcher: + +``` +$includes $regex /[Hh]ello/ +# would match ["A", "B", "hello"] +``` + +Or matching another `$includes` to search nested arrays for a value: + +``` +$includes $includes 5 +# would match [[1, 2, 3], [4, 5, 6]] +``` + +## Examples + +Here is a simple example that checks if an object with the specified "id" property is included in a returned data array. + +```yaml +tests: + - test: Recipe included in list + request: + method: GET + url: http://localhost:3000/recipes + expect: + body: + data: $includes "{ "id": "${RECIPE_ID}" }" +``` + +For a more specific check, the expected item can first be defined as a variable in the suite. The expected value can still be defined as YAML and later will be expanded to the full value when the test is run. + +```yaml +variables: + RECIPE: + name: Guacamole + description: A delicious classic guacamole recipe. + time: 10 + servings: 6 + ingredients: + - 3 avocados + - 1/2 red onion + - 1 lime + - 1 green bell pepper + - 1 jalapeno pepper + - 1/2 tsp cumin + - 1/2 tsp salt + - 1/2 tsp red pepper + instructions: [ + "Roughly chop onion, green pepper, and jalapeno and add to food processor. Pulse 2-3 times.", + "Cut and remove seeds from avocados, use spoon to scoop and mash in a bowl.", + "Mix in the vegetables, seasonings, and lime juice squeezed from a fresh lime." ] + +tests: + - test: Recipe included in list + request: + method: GET + url: http://localhost:3000/recipes + expect: + body: + data: $includes ${RECIPE} +``` diff --git a/docs/src/matchers/length.md b/docs/src/matchers/length.md new file mode 100644 index 0000000..23ca306 --- /dev/null +++ b/docs/src/matchers/length.md @@ -0,0 +1,34 @@ +# $length + +The `$length` matcher can be used to assert the length of arrays, objects, and strings. You can assert using exact values, or a custom _value matcher_. + +## Usage + +``` +$length +``` + +Valid arguments include: + +```yaml +$length 5 # length is exactly 5 +$length > 5 # length is greater than 5 +$length >= 5 # length is greater than or equal to 5 +$length < 5 # length is less than 5 +$length <= 5 # length is less than or equal to 5 +$length == 5 # length is exactly 5, same as first example +``` + +## Example + +```yaml +tests: + - test: At least two comments + description: The comments field should have at least two comments + request: + method: GET + url: http://localhost:3000/post/${POST_ID} + expect: + post: + comments: $length >= 2 +``` diff --git a/docs/src/matchers/regex.md b/docs/src/matchers/regex.md new file mode 100644 index 0000000..592e892 --- /dev/null +++ b/docs/src/matchers/regex.md @@ -0,0 +1,31 @@ +# $regex + +The `$regex` matcher uses a provided regular expression to determine whether a match is found in the specified response field. + +## Usage + +The `$regex` matcher takes one argument, a regular expression wrapped in forward slash characters. + +``` +$regex // +``` + +> Note: Unless you include start/end matchers `^` and `$` in your regular expression, `$regex` will match _any_ contained instances of the value. The `$regex` matcher also only matches strings - any attempt to match another value type will simply result in the test failing. + +## Example + +This example matches the `description` fields returned by the response body and matches any description which contains one or more matches of the word "guacamole" with a case-insensitive 'G'. + +```yaml + - test: Recipe description mentions guacamole + description: "Not sure why, but the description should mention guacamole at least once" + request: + method: GET + url: ${BASE_URL}/recipes/${RECIPE_ID} + expect: + status: 2xx + body: + id: ${RECIPE_ID} + name: Guacamole + description: $regex /([Gg]uacamole)+/ +``` diff --git a/docs/src/writing_tests.md b/docs/src/writing_tests.md index d9c63f5..8167738 100644 --- a/docs/src/writing_tests.md +++ b/docs/src/writing_tests.md @@ -1 +1,56 @@ # Writing Tests + +## Example + +```yaml + - test: Get recipe + description: "Should be able to get recipe information" + request: + method: GET + url: ${BASE_URL}/recipes/${RECIPE_ID} + expect: + status: 2xx + body: + id: ${RECIPE_ID} + name: Guacamole + ingredients: $exists +``` + +Let's review each component of this example. + +- `test` - This is the name of the test as it will appear in your terminal. Keep this short. +- `description` - This is an optional description that describes your test in more detail. +> Note: As of version 0.1.0, `description` does not actually do anything and is akin to a comment. However, in future updates it will hopefully be integrated with any kind of test report output. +- `request` - The HTTP request that you want Capti to make. + - `method` - The HTTP method - one of "GET", "POST", "PATCH", "PUT", or "DELETE" + - `url` - The URL to which the request should be made. In the example, _variables_ are used to substitute in parts of the URL. See the section on [variables](variables.md) for more information. +- `expect` - The HTTP response that you expect to get back. + - `status` - The [HTTP Status Code](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status) you expect your endpoint to return. + - `body` - The response body you expect to get back - in this case the body has three fields `id`, `name`, and `ingredients`. `id` is being matched to a variable, `name` must exactly match the word "Guacamole", and `ingredients` is using the `$exists` matcher to verify that the ingredients field is non-null. Please see the [matcher section](./matchers.md) for more information on matchers. + +## Implicit Matching + +Omitted fields in your `expect` definition are matched implicitly with the response. This means that you can focus on testing individual components of the response body by only defining a subset of the response. For example, if the response body returned from your server looks like this: + +```bash +{ + "__v": 0, + "_id": "65d204a107b727ac2667e82f", + "displayName": "john-smith", + "email": "testuser3@test.com", + "id": "65d204a107b727ac2667e82f" +} +``` + +And all you care about is making sure the `displayName` field is correct, you can simply define your `expect` definition like so: + +```yaml + expect: + body: + displayName: john-smith +``` + +This test will pass. You do not need to include the other fields to get a passing test. + +> Note: In some cases, you do want to ensure a field is _absent_ from the response. For example, say you want to make sure the `password` field does not exist in the body. For this, you can use the `$absent` matcher. Review [Matchers](./matchers.md) for more information on the `$absent` matcher and other matchers. + diff --git a/src/m_value/matcher_definition.rs b/src/m_value/matcher_definition.rs index ef7dc3f..69993c3 100644 --- a/src/m_value/matcher_definition.rs +++ b/src/m_value/matcher_definition.rs @@ -81,7 +81,7 @@ impl TryFrom<&str> for MatcherDefinition { if let Some(key_candidate) = parts.next() { if let Some(_) = MatcherMap::get_matcher(key_candidate) { let args = parts.map(|s| s.into()).collect::>().join(" "); - let args = serde_yaml::from_str::(&args).unwrap_or(MValue::Null); + let args = serde_yaml::from_str::(&args).unwrap_or(MValue::String(args)); return Ok(MatcherDefinition { match_key: key_candidate.to_string(), args, diff --git a/src/runner/runner.rs b/src/runner/runner.rs index bf39cd5..5edbb67 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -22,8 +22,22 @@ impl Runner { || e.path().extension().unwrap_or_default() == "yml" }) .map(|e| e.path().to_path_buf()) - .filter_map(|path| std::fs::read_to_string(path).ok()) - .filter_map(|data| serde_yaml::from_str::(&data).ok()) + .filter_map(|path| { + std::fs::read_to_string(path) + .map_err(|e| { + eprintln!("Failed to read suite: {}", e); + e + }) + .ok() + }) + .filter_map(|data| { + serde_yaml::from_str::(&data) + .map_err(|e| { + eprintln!("Failed to parse suite: {}", e); + e + }) + .ok() + }) .collect::>(); progress_println!("Found {} test suites", suites.len()); diff --git a/src/variables/variable_map.rs b/src/variables/variable_map.rs index f642069..5da9c04 100644 --- a/src/variables/variable_map.rs +++ b/src/variables/variable_map.rs @@ -127,7 +127,7 @@ impl VariableMap { None => MValue::Null, }; - result.populate_variables(self); + result.populate_variables(self)?; return Ok(result); } @@ -185,8 +185,6 @@ impl Deref for VariableMap { #[cfg(test)] mod test { - use serde_json::json; - use super::*; #[test] diff --git a/test_app/tests/recipe_create.yaml b/test_app/tests/recipe_create.yaml index 05229d9..be034a2 100644 --- a/test_app/tests/recipe_create.yaml +++ b/test_app/tests/recipe_create.yaml @@ -29,6 +29,8 @@ variables: "Cut and remove seeds from avocados, use spoon to scoop and mash in a bowl.", "Mix in the vegetables, seasonings, and lime juice squeezed from a fresh lime." ] + + tests: - test: Create new user request: @@ -93,7 +95,7 @@ tests: url: ${BASE_URL}/recipes expect: status: 2xx - body: '$includes ${RECIPE}' + body: $includes ${RECIPE} - test: User only has one recipe description: User should only show one recipe in their list @@ -102,7 +104,7 @@ tests: url: ${BASE_URL}/recipes expect: status: 2xx - body: $length 1 + body: $length >= 1 - test: Delete recipe description: "Deletes the new recipe" From 969d6ba4b6ed75574865025cd8c34cf4d40319c3 Mon Sep 17 00:00:00 2001 From: Wvaviator Date: Sun, 18 Feb 2024 09:42:17 -0600 Subject: [PATCH 29/34] add empty pages --- docs/src/SUMMARY.md | 8 ++++++++ docs/src/configuration.md | 1 + docs/src/configuration/config.md | 1 + docs/src/configuration/scripts.md | 1 + docs/src/configuration/suites.md | 1 + docs/src/variables.md | 1 + docs/src/variables/extracting.md | 1 + 7 files changed, 14 insertions(+) create mode 100644 docs/src/configuration.md create mode 100644 docs/src/configuration/config.md create mode 100644 docs/src/configuration/scripts.md create mode 100644 docs/src/configuration/suites.md create mode 100644 docs/src/variables.md create mode 100644 docs/src/variables/extracting.md diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index ee795a3..c2f0d96 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -6,6 +6,11 @@ - [Getting Started](./getting_started.md) - [Testing Basics](./writing_tests.md) +- [Configuration](./configuration.md) + - [Config File](./configuration/config.md) + - [Setup Scripts](./configuration/scripts.md) + - [Suite Configuration](./configuration/suites.md) + - [Matchers](./matchers.md) - [$exists](./matchers/exists.md) - [$absent](./matchers/absent.md) @@ -14,6 +19,9 @@ - [$empty](./matchers/empty.md) - [$includes](./matchers/includes.md) +- [Variables](./variables.md) + - [Extracting Variables](./variables/extracting.md) + ---- - [Contributing](./contributing.md) diff --git a/docs/src/configuration.md b/docs/src/configuration.md new file mode 100644 index 0000000..a025a48 --- /dev/null +++ b/docs/src/configuration.md @@ -0,0 +1 @@ +# Configuration diff --git a/docs/src/configuration/config.md b/docs/src/configuration/config.md new file mode 100644 index 0000000..8e6a6eb --- /dev/null +++ b/docs/src/configuration/config.md @@ -0,0 +1 @@ +# Config File diff --git a/docs/src/configuration/scripts.md b/docs/src/configuration/scripts.md new file mode 100644 index 0000000..f272a98 --- /dev/null +++ b/docs/src/configuration/scripts.md @@ -0,0 +1 @@ +# Setup Scripts diff --git a/docs/src/configuration/suites.md b/docs/src/configuration/suites.md new file mode 100644 index 0000000..9382b16 --- /dev/null +++ b/docs/src/configuration/suites.md @@ -0,0 +1 @@ +# Suite Configuration diff --git a/docs/src/variables.md b/docs/src/variables.md new file mode 100644 index 0000000..ee1fba4 --- /dev/null +++ b/docs/src/variables.md @@ -0,0 +1 @@ +# Variables diff --git a/docs/src/variables/extracting.md b/docs/src/variables/extracting.md new file mode 100644 index 0000000..f85da03 --- /dev/null +++ b/docs/src/variables/extracting.md @@ -0,0 +1 @@ +# Extracting Variables From e3646c5b720cc6d6479df841827ca3a0cccdf1c7 Mon Sep 17 00:00:00 2001 From: Alexander Durham Date: Mon, 19 Feb 2024 12:16:58 -0600 Subject: [PATCH 30/34] add more docs for variables and config --- docs/src/SUMMARY.md | 3 + docs/src/configuration/config.md | 27 ++++++++ docs/src/configuration/scripts.md | 74 ++++++++++++++++++++++ docs/src/configuration/suites.md | 35 +++++++++++ docs/src/configuration/tests.md | 11 ++++ docs/src/variables.md | 25 ++++++++ docs/src/variables/complex.md | 94 ++++++++++++++++++++++++++++ docs/src/variables/env_variables.md | 30 +++++++++ docs/src/variables/extracting.md | 86 +++++++++++++++++++++++++ docs/src/writing_tests.md | 97 ++++++++++++++++++++++++++++- test_app/tests/recipe_create.yaml | 39 +++++------- 11 files changed, 497 insertions(+), 24 deletions(-) create mode 100644 docs/src/configuration/tests.md create mode 100644 docs/src/variables/complex.md create mode 100644 docs/src/variables/env_variables.md diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index c2f0d96..caca2a5 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -10,6 +10,7 @@ - [Config File](./configuration/config.md) - [Setup Scripts](./configuration/scripts.md) - [Suite Configuration](./configuration/suites.md) + - [Test Configuration](./configuration/tests.md) - [Matchers](./matchers.md) - [$exists](./matchers/exists.md) @@ -20,7 +21,9 @@ - [$includes](./matchers/includes.md) - [Variables](./variables.md) + - [Complex Variables](./variables/complex.md) - [Extracting Variables](./variables/extracting.md) + - [Environment Variables](./variables/env_variables.md) ---- diff --git a/docs/src/configuration/config.md b/docs/src/configuration/config.md index 8e6a6eb..d2b679d 100644 --- a/docs/src/configuration/config.md +++ b/docs/src/configuration/config.md @@ -1 +1,28 @@ # Config File + +Capti config files enable you to set some settings that apply to all your test suites and infuence how test suites are processed. Many of the same configuration options are available on a per-suite basis as well. One useful component of the configuration is specifying scripts that should run before and after your tests - for example, starting your server. + +## Setup + +To create a config file, simply include a file named `capti-config.yaml` in your tests folder. This will automatically be parsed as a configuration for Capti. + +### Custom Config + +If you would instead prefer to name your config differently, or include the config in a location separate from your tests, you can specify the `--config` or `-c` argument when running Capti. For example, say you want to keep your config file in a separate directory in your project: + +``` +. +├── src/ +│ └── index.ts +├── tests/ +│ └── hello.yaml +├── config/ +│ └── capti.yaml +└── .gitignore +``` + +You can configure your script to run + +```bash +$ capti --path ./tests --config ./config/capti.yaml +``` \ No newline at end of file diff --git a/docs/src/configuration/scripts.md b/docs/src/configuration/scripts.md index f272a98..c00e852 100644 --- a/docs/src/configuration/scripts.md +++ b/docs/src/configuration/scripts.md @@ -1 +1,75 @@ # Setup Scripts + +If you would like Capti to run commands or scripts before executing tests, whether for continuous integration workflows or just for convenience, you can specify scripts to run and optional `wait_until` parameter to determine when to continue with executing your tests or additional scripts. + +## Adding Scripts + +Setup scripts should be listed in sequential order under `before_all` or `after_all` in your config file. + +```yaml +# tests/capti-config.yaml + +setup: + before_all: + - script: npm start + description: start server + wait_until: output 'Server running on port 3000' +``` + +You can also additionally include `before_each` and `after_each` scripts in your individual test suites. Keep in mind that config-level scripts will all execute first. + +```yaml +# tests/hello.yaml +suite: /hello endpoint tests +description: tests the various HTTP methods of the /hello endpoint +setup: + before_all: + - script: echo 'starting hello suite' + before_each: + - script: ./scripts/reset-test-db.sh + desription: reset test db + wait_until: finished +``` + +## Wait Until Options + +There are a few different options to choose from when deciding how to wait for your scripts to finish. By default, if `wait_until` is not included, execution will immediately continue with your script running in the background. This is not always what you want - for example when starting a server, you need to give it time to fully spin up before you start testing its endpoints. + +- `wait_until: finished` - This executes the command/script/program and waits synchronously for it to finish before proceeding. +- `wait_until: 5 seconds` - This executes the script and then waits for the specified number of seconds before continuing. +- `wait_until: port 3000` - This executes the script and waits for the specified port to open. If the port already has an open connection, the script will not execute. +- `wait_until: output 'Server listening on port 3000` - This executes the script and then waits for the specified console output from your server. This is useful in some cases where the port may be open but the server is still not quite ready to take requests. + +## Examples + +Here is a simple cross-platform script to start a server and check that the port connection is open before proceeding. + +```yaml +setup: + before_all: + - description: start app server + script: NODE_ENV=test && npm start + wait_until: port 3000 +``` + +Here is an example from a project that uses Docker Compose to spin up both a database and a server. This Unix script checks if Docker Compose is already running, and if not - starts it. If it is already started, the `wait_until` output is still detected because of the call to `echo` the same output text. Note - make sure you update the output text to match your server's log message when it becomes ready. + +```yaml +setup: + before_all: + - description: "Start db and server" + wait_until: output "Listening on 3000" + script: > + if ! docker-compose ps | grep -q " Up "; then + docker-compose up + else + echo "Listening on 3000" + fi +``` + +## Considerations + +Running setup scripts is entirely optional and merely provides a convenience for development. It is not necessary and you may instead choose to start your server manually first and then run Capti. + +> Why doesn't Capti just integrate directly with the server? +> The goal of Capti is to provide the convenience of platform-agnostic test suites to run with your project, without directly coupling with your server (and behaving more like a user). If you want a framework that more tightly integrates with your server, you can look into a tool like supertest for NodeJS or MockMvc with Java/Spring. \ No newline at end of file diff --git a/docs/src/configuration/suites.md b/docs/src/configuration/suites.md index 9382b16..ace2da9 100644 --- a/docs/src/configuration/suites.md +++ b/docs/src/configuration/suites.md @@ -1 +1,36 @@ # Suite Configuration + +You have a few options to work with when configuring your test suites. You can define [setup scripts](./scripts.md) that execute before or after your tests, specify whether tests should run in parallel or sequentially, and specify static variables to be used throughout your test suite. + +## Setup Scripts + +See [setup scripts](./scripts.md) for more information on how to create setup scripts. These scripts execute command line programs or utilities before and after your tests, and can be useful if you want to do some specific configuration, like resetting a database, before testing. + +## Parallel Testing + +In general, you should prefer sequential testing over parallel testing. Capti test suites are each meant to simulate individual users interacting with your application, and a user would not typically be visiting multiple endpoints concurrently. + +> Note: When you have multiple test suites defined, the test _suites_ will always run concurrently. Each suite should be designed to simulate a user, and multiple users should be able to interact with your API concurrently and deterministically. Your suites should never rely on the state of other test suites. The individual _tests_ in a suite should, in the majority of cases, run sequentially. + +There are some cases in which the tests within a suite should run in parallel. One example would be if you are grouping together multiple tests of several different _public_ and _stateless_ endpoints. In these cases, you can specify in your test suites that all tests should run in parallel with `parallel: true`. + +```yaml +suite: "Published recipes" +description: "This suite tests multiple sequential accesses to the public endpoints returning published recipe information." +parallel: true +``` + +> Note: You cannot _extract_ variables when specifying `parallel: true`. Referencing an extracted variable in a later request is not possible when all requests run concurrently. + +## Variables + +You can define static variables to be used throughout the tests in your suites with the `variables:` mapping. These variables will expand to the specified value, sequence, or mapping when they are used. You can learn more in the [variables chapter](../variables.md). + +```yaml +suite: "Create Recipe" +description: "This suite involves creating a new recipe and fetching its information." +variables: + BASE_URL: http://localhost:3000 + USER_EMAIL: recipe1@tests.com + USER_PASSWORD: abc123! +``` \ No newline at end of file diff --git a/docs/src/configuration/tests.md b/docs/src/configuration/tests.md new file mode 100644 index 0000000..8206d0e --- /dev/null +++ b/docs/src/configuration/tests.md @@ -0,0 +1,11 @@ +# Test Configuration + +In addition to defining a `request` and an `expect` mapping for each test, you can also define the following settings. + +## Print Response + +By setting `print_response: true` on your tests, the complete response status, headers, and body will be printed to the console when your test is run. This can be useful for debugging a failing test. + +## Should Fail + +Setting `should_fail: true` on your test, as expected, will assert that the test should fail. In most cases, however, you should be able to acheive this functionality with the right [matchers](../matchers.md) in your `expect` definition. \ No newline at end of file diff --git a/docs/src/variables.md b/docs/src/variables.md index ee1fba4..ffe687e 100644 --- a/docs/src/variables.md +++ b/docs/src/variables.md @@ -1 +1,26 @@ # Variables + +Variables are one component of Capti test suites that provide a lot of power. They enable features such as testing authorization flows, embedding complex mappings or sequences as matcher arguments, or simply reducing boilerplate and making your tests more DRY. + +Variables can be defined statically as part of the suite configuration, or dynamically with `extract` definitions. + +## Simple Values + +The basic usage of variables is to reduce repetitive values, such as a `BASE_URL` for your endpoints. + +```yaml +suite: "Create Recipe" +description: "This suite involves creating a new recipe and fetching its information." +variables: + BASE_URL: http://localhost:3000 + +tests: + - test: Fetch recipes + description: List of public recipes contains at least one recipe + request: + method: GET + url: ${BASE_URL}/recipes + expect: + status: 2xx + body: $length >= 1 +``` \ No newline at end of file diff --git a/docs/src/variables/complex.md b/docs/src/variables/complex.md new file mode 100644 index 0000000..c5c96c5 --- /dev/null +++ b/docs/src/variables/complex.md @@ -0,0 +1,94 @@ +# Complex Variables + +Beyond simple values, variables can be used to define entire mappings or sequences, and even matchers. Variables can reference other variables, and can be used in place of entire `request` mappings or response `body` mappings. Additionally, some matchers that accept embedded mappings or sequences (such as [$includes](../matchers/includes.md)), it can be more ergonomic to predefine these mappings as variables. + +## Nesting Variables + +You can reference, or nest, variables within other variables. Nested variables are recursively resolved and expanded. + +```yaml +suite: "Create Recipe" +description: "This suite involves creating a new recipe and fetching its information." +variables: + BASE_URL: http://localhost:3000 + RECIPE_URL: ${BASE_URL}/recipes +``` + +## Mappings or Sequences + +You can define entire mappings (objects) or sequences (arrays) as variables and use them in your tests. In the example below, and entire `RECIPE` object is defined as a variable and then used as the request body for creating the recipe, and later used as an `expect` definition when verifying the correct recipe is returned. + +```yaml +suite: 'Create Recipe' +description: 'This suite involves creating a new recipe and fetching its information.' +variables: + RECIPE_URL: http://localhost:3000/recipes + USER_EMAIL: recipe1@tests.com + USER_PASSWORD: hG98s4%%phG + RECIPE: + name: Guacamole + description: > + A delicious classic guacamole recipe. + time: 10 + servings: 6 + ingredients: + - 3 avocados + - 1/2 red onion + - 1 lime + - 1 green bell pepper + - 1 jalapeno pepper + - 1/2 tsp cumin + - 1/2 tsp salt + - 1/2 tsp red pepper + instructions: + [ + 'Roughly chop onion, green pepper, and jalapeno and add to food processor. Pulse 2-3 times.', + 'Cut and remove seeds from avocados, use spoon to scoop and mash in a bowl.', + 'Mix in the vegetables, seasonings, and lime juice squeezed from a fresh lime.', + ] + +tests: + - test: Create new recipe + description: Create a new recipe + request: + method: POST + url: ${RECIPE_URL} + headers: + Content-Type: application/json + body: ${RECIPE} + expect: + status: 2xx + body: ${RECIPE} + extract: + body: + id: ${RECIPE_ID} + + - test: Get recipe + description: 'Should be able to get recipe information' + request: + method: GET + url: ${RECIPE_URL}/${RECIPE_ID} + expect: + status: 2xx + body: ${RECIPE} +``` + +## Embedded Matcher Arguments + +When using a matcher like [$includes](../matchers/includes.md) that expects any value, mapping, or sequence as an argument - defining something complex like an object requires using JSON strings (because of the limitations of YAML nesting in strings). Instead of defining a complex JSON string and wearing out your `"` key, you can define the object you expect to find in the array as a variable. + +For this example, reference the previous example's use of the `RECIPE` variable. + +```yaml + - test: Recipe in list + description: Recipe should be visible when listing all recipes + request: + method: GET + url: ${RECIPE_URL}/all + expect: + status: 2xx + body: + recipes: $includes ${RECIPE} +``` + +At runtime, the `${RECIPE}` will be expanded to the full mapping as defined above, and will then proceed to be correctly parsed as an object to be matched. \ No newline at end of file diff --git a/docs/src/variables/env_variables.md b/docs/src/variables/env_variables.md new file mode 100644 index 0000000..1466e1a --- /dev/null +++ b/docs/src/variables/env_variables.md @@ -0,0 +1,30 @@ +# Environment Variables + +By default, if you specify a variable in your tests but the declaration for that value cannot be found, Capti will then default to searching your local environment for that variable. + +For example, if you have a `SERVER_URL` variable defined in your local environment, you can use that like any other variable: + +```yaml + - test: Get a user + request: + method: GET + url: ${SERVER_URL}/users + expect: + status: 200 +``` + +However, since variables defined in your test suite take precendence, if you were to define the SERVER_URL in your suite configuration, that value will be used instead. + +```yaml +suite: User endpoint tests +variables: + SERVER_URL: http://localhost:4000 + +tests: + - test: Get a user + request: + method: GET + url: ${SERVER_URL}/users + expect: + status: 200 +``` \ No newline at end of file diff --git a/docs/src/variables/extracting.md b/docs/src/variables/extracting.md index f85da03..31b3f69 100644 --- a/docs/src/variables/extracting.md +++ b/docs/src/variables/extracting.md @@ -1 +1,87 @@ # Extracting Variables + +Variable extraction enables your tests to adjust dynamically to whatever scenario you've decided to test. You can extract auto-generated IDs from your database, or JWT tokens for use in making subsequent authorized requests. + +## Simple Extraction + +To extract a variable from a repsonse, create an `extract` mapping definition on your test. The `extract` definition follows the exact same layout as `expect`, except variables placed here will be populated, instead of expanded. + +In this example, the `USER_ID` which was auto-generated by the db is extracted, as well as the authorization token. + +```yaml + - test: "Sign up" + description: "Signing up as a new user for the site" + + request: + method: POST + url: "${BASE_URL}/auth/signup" + headers: + Content-Type: application/json + body: + email: ${USER_EMAIL} + displayName: john-smith + password: ${USER_PASSWORD} + + expect: + status: 2xx + headers: + Content-Type: $regex /json/ + body: + id: $exists + email: ${USER_EMAIL} + displayName: john-smith + password: $absent + + extract: + headers: + Authorization: Bearer ${AUTH_TOKEN} + body: + id: ${USER_ID} +``` + +Those collected variables can then be used in subsequent requests. In the following example, the `USER_ID` we collected is used as a URL path param, and the `AUTH_TOKEN` we collected is used to authenticate the request. + +```yaml + - test: "Access profile data" + description: "After signing in, the user can get their profile data" + + request: + method: GET + url: ${BASE_URL}/user/${USER_ID} + headers: + Authorization: Bearer ${AUTH_TOKEN} + + expect: + status: 2xx + body: + firstName: $exists + lastName: $exists + imageUrl: $regex /.*\.png$/ +``` + +## Embedded Extraction + +Notice in the above `extract` example that `${AUTH_TOKEN}` is placed after "Bearer" - this ensures that only the token is extracted, and not the word "Bearer". That's not particularly useful in this case since the word "Bearer" is still going to be used in subsequent tests anyway, however there are some cases where needed values are embedded in long strings of content. This extraction works as if the entire string were a regex with your variable as a capture group. + +For example, if your `extract` definition has the following: + +```yaml + extract: + body: + message: The quick ${COLOR} fox jumped over the ${ADJECTIVE} dog. +``` + +And the actual response looks like this: + +```yaml + body: + message: The quick brown fox jumped over the lazy dog. +``` + +Then the resulting values for `${COLOR}` and `${ADJECTIVE}` will evaluate to "brown" and "lazy", respectively. + +## Considerations + +- Extracted variables cannot be used in suites with the configuration option `parallel: true` set. This is because tests running in parallel cannot reference variables extracted from each other. +- Currently, extracted values can only be strings. Unlike statically defined variables, you cannot extract entire mappings or sequences from a response. +- Using extracted variables in subsequent tests creates an inherent dependency of those tests on the test which performs the extraction. If a test with an `extract` definition fails, all its dependent tests will fail as well. Keep this in mind when you see many failures - it may just be one test causing the issue. \ No newline at end of file diff --git a/docs/src/writing_tests.md b/docs/src/writing_tests.md index 8167738..959405b 100644 --- a/docs/src/writing_tests.md +++ b/docs/src/writing_tests.md @@ -50,7 +50,102 @@ And all you care about is making sure the `displayName` field is correct, you ca displayName: john-smith ``` -This test will pass. You do not need to include the other fields to get a passing test. +This test will pass. You do not need to include the other fields to get a passing test. In fact, if your test specifies an _empty_ `expect` definition, it will _always_ pass unless the test throws an error. > Note: In some cases, you do want to ensure a field is _absent_ from the response. For example, say you want to make sure the `password` field does not exist in the body. For this, you can use the `$absent` matcher. Review [Matchers](./matchers.md) for more information on the `$absent` matcher and other matchers. +## Matchers + +You can specify exact values in the `expect` section of each test, or tests can also be configured with special matchers. + +```yaml +tests: + - test: "get hello" + description: "hello endpoint responds with some type of greeting" + request: + method: GET + url: "http://localhost:3000/hello" + expect: + status: 2xx # match any 200 level status code + headers: + Content-Type: application/json # exact match + body: + message: $regex /[Hh]ello/ # match based on regex + currentTime: $exists # match anything as long as it is present +``` + +For more information on matchers, review the [matchers chapter](./matchers.md). + +## Variables + +Static variables can be defined for each test suite with the `variables` option, and then used in test definitions like `${this}`. When the test is run, each variable will be expanded in place. + +```yaml +suite: "User Signup" +description: "Confirms that protected routes cannot be accessed until the user signs up." +variables: + BASE_URL: "http://localhost:3000" + USER_EMAIL: "testuser2@test.com" + USER_PASSWORD: "F7%df12UU9lk" + +tests: + - test: "Sign in" + description: "The user should be able to sign in with email and password" + request: + method: POST + url: "${BASE_URL}/auth/signin" + headers: + Content-Type: application/json + body: + email: ${USER_EMAIL} + password: ${USER_PASSWORD} + expect: + status: 2xx + body: + id: $exists + email: ${USER_EMAIL} + password: $absent +``` + +Environment variables can be referenced in the same way. If a variable is set in both your local environment and in the `variables` section, the value specified in the `variables` section will take precedence. + +Variables can also be "extracted" from responses and used in subsequent tests by defining an `extract` section for a test. + +```yaml +tests: + - test: "sign in" + description: "Sign in as the test user" + request: + method: POST + url: "${BASE_URL}/auth/signup" + headers: + Content-Type: application/json + body: + email: ${USER_EMAIL} # email and password defined as variables in the test suite for easy reuse throughout tests + password: ${USER_PASSWORD} + expect: + status: 2xx + extract: + headers: + Authorization: Bearer ${JWT_TOKEN} # extracts the token variable from the response + body: + userId: ${USER_ID} # extracts the user id generated by the database + + - test: "access protected route" + description: "After signing in, the user can get their profile data" + request: + method: GET + url: "${BASE_URL}/profile/${USER_ID}" # extracted variables can be used just like any other + headers: + Authorization: Bearer ${JWT_TOKEN} # great for auth flows + expect: + status: 2xx + body: + firstName: $exists + lastName: $exists + imageUrl: $regex /.*\.png$/ +``` + +> Note: Each suite manages its own cookies internally, enabling session authentication to work automatically within a suite. There is no need to extract cookies to carry over between requests. + +For more information on variables, please review the [variables chapter](./variables.md). diff --git a/test_app/tests/recipe_create.yaml b/test_app/tests/recipe_create.yaml index be034a2..8c0c1c4 100644 --- a/test_app/tests/recipe_create.yaml +++ b/test_app/tests/recipe_create.yaml @@ -1,14 +1,10 @@ -suite: "Create Recipe" -description: "This suite involves creating a new recipe and fetching its information." +suite: 'Create Recipe' +description: 'This suite involves creating a new recipe and fetching its information.' variables: BASE_URL: http://localhost:3000 RECIPE_URL: ${BASE_URL}/recipes USER_EMAIL: recipe1@tests.com USER_PASSWORD: hG98s4%%phG - USER_BODY_EXPECT: - id: $exists - email: ${USER_EMAIL} - displayName: john-smith RECIPE: name: Guacamole description: > @@ -24,12 +20,12 @@ variables: - 1/2 tsp cumin - 1/2 tsp salt - 1/2 tsp red pepper - instructions: [ - "Roughly chop onion, green pepper, and jalapeno and add to food processor. Pulse 2-3 times.", - "Cut and remove seeds from avocados, use spoon to scoop and mash in a bowl.", - "Mix in the vegetables, seasonings, and lime juice squeezed from a fresh lime." ] - - + instructions: + [ + 'Roughly chop onion, green pepper, and jalapeno and add to food processor. Pulse 2-3 times.', + 'Cut and remove seeds from avocados, use spoon to scoop and mash in a bowl.', + 'Mix in the vegetables, seasonings, and lime juice squeezed from a fresh lime.', + ] tests: - test: Create new user @@ -44,13 +40,16 @@ tests: password: ${USER_PASSWORD} expect: status: 2xx - body: ${USER_BODY_EXPECT} + body: + id: $exists + email: ${USER_EMAIL} + displayName: john-smith extract: body: id: ${USER_ID} - test: New recipe fails missing required fields - description: "If required fields are missing, the request fails." + description: 'If required fields are missing, the request fails.' request: method: POST url: ${BASE_URL}/recipes @@ -80,7 +79,7 @@ tests: id: ${RECIPE_ID} - test: Get recipe - description: "Should be able to get recipe information" + description: 'Should be able to get recipe information' request: method: GET url: ${BASE_URL}/recipes/${RECIPE_ID} @@ -107,7 +106,7 @@ tests: body: $length >= 1 - test: Delete recipe - description: "Deletes the new recipe" + description: 'Deletes the new recipe' request: method: DELETE url: ${BASE_URL}/recipes/${RECIPE_ID} @@ -115,7 +114,7 @@ tests: status: 2xx - test: Recipe is gone - description: "The recipe is really gone after deletion" + description: 'The recipe is really gone after deletion' request: method: GET url: ${BASE_URL}/recipes/${RECIPE_ID} @@ -128,9 +127,3 @@ tests: url: ${BASE_URL}/auth/user expect: status: 2xx - - - - - - From 9fdc9747847104f62afcb644cb5756be3f07f820 Mon Sep 17 00:00:00 2001 From: Wvaviator Date: Mon, 19 Feb 2024 20:49:28 -0600 Subject: [PATCH 31/34] add not matcher and fix nested matching --- docs/src/SUMMARY.md | 1 + docs/src/configuration/tests.md | 60 ++++++++++++++++++++++++++++++- docs/src/matchers.md | 11 +++--- docs/src/matchers/not.md | 44 +++++++++++++++++++++++ docs/src/variables.md | 5 +-- src/m_value/matcher_definition.rs | 3 +- src/m_value/matcher_map.rs | 1 + src/m_value/mod.rs | 1 + src/m_value/mvalue_wrapper.rs | 36 +++++++++++++++++++ src/m_value/std_matchers/mod.rs | 2 ++ src/m_value/std_matchers/not.rs | 23 ++++++++++++ test_app/tests/recipe_create.yaml | 37 +++++++++++++++++++ 12 files changed, 215 insertions(+), 9 deletions(-) create mode 100644 docs/src/matchers/not.md create mode 100644 src/m_value/mvalue_wrapper.rs create mode 100644 src/m_value/std_matchers/not.rs diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index caca2a5..3b4e8b9 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -19,6 +19,7 @@ - [$length](./matchers/length.md) - [$empty](./matchers/empty.md) - [$includes](./matchers/includes.md) + - [$not](./matchers/not.md) - [Variables](./variables.md) - [Complex Variables](./variables/complex.md) diff --git a/docs/src/configuration/tests.md b/docs/src/configuration/tests.md index 8206d0e..64bf9bd 100644 --- a/docs/src/configuration/tests.md +++ b/docs/src/configuration/tests.md @@ -6,6 +6,64 @@ In addition to defining a `request` and an `expect` mapping for each test, you c By setting `print_response: true` on your tests, the complete response status, headers, and body will be printed to the console when your test is run. This can be useful for debugging a failing test. +```bash +== Response: (Sign up) ======= + + Status: 200 + + Headers: + ▹ "x-powered-by": "Express" + ▹ "content-type": "application/json; charset=utf-8" + ▹ "content-length": "130" + ▹ "etag": "W/"82-l0Mhda3RFUb75lW/cRtznG5a9jI"" + ▹ "set-cookie": "connect.sid=s%3A0D8I6wmav5gUclgFPWA9u9WvCQ4oSNo7.u7xk7r6XkMbMdwsVtwArBZ1Q0DFT0pzo72tWRuh9JA8; Path=/; HttpOnly" + ▹ "date": "Sat, 17 Feb 2024 21:55:29 GMT" + ▹ "connection": "keep-alive" + ▹ "keep-alive": "timeout=5" + + Body: + { + "email": "testuser3@test.com", + "displayName": "john-smith", + "_id": "65d12b5182456857b2b9c8ce", + "__v": 0, + "id": "65d12b5182456857b2b9c8ce" + } + +============================== +``` + + ## Should Fail -Setting `should_fail: true` on your test, as expected, will assert that the test should fail. In most cases, however, you should be able to acheive this functionality with the right [matchers](../matchers.md) in your `expect` definition. \ No newline at end of file +Setting `should_fail: true` on your test, as expected, will assert that the test should fail. In most cases, however, you should be able to acheive this functionality with the right [matchers](../matchers.md) in your `expect` definition. + +This example uses the `should_fail` attribute to ensure the test does not pass with a successful status. + +```yaml + - test: "Protected route" + description: "Attempting to access protected route without signin or signup" + should_fail: true + request: + method: GET + url: "${BASE_URL}/recipes" + expect: + status: 2xx + body: + recipes: $exists +``` + +However, a more declarative and idiomatic pattern would be to use matchers to assert the expected 400-level status code and absent request body information. This also enables asserting the correct error status code - in the case that the endpoint actually returns a 404 or 500-level status, the above test would pass, whereas this test would still detect the error and fail. + +```yaml + - test: "Protected route" + description: "Attempting to access protected route without signin or signup" + request: + method: GET + url: "${BASE_URL}/recipes" + expect: + status: 403 + body: + recipes: $absent +``` + diff --git a/docs/src/matchers.md b/docs/src/matchers.md index adcf83e..3581c6a 100644 --- a/docs/src/matchers.md +++ b/docs/src/matchers.md @@ -1,6 +1,6 @@ # Matchers -Matchers are what make Capti so flexible when writing tests for HTTP endpoints. Often we don't know exactly what we're going to get back from the server (e.g. a unique ID value generated by the database). Using matchers, we can verify that the the response returns any value, or returns value to follows some sort of pattern or structure. +Matchers are what make Capti so flexible when writing tests for HTTP endpoints. Often we don't know exactly what we're going to get back from the server (e.g. a unique ID value generated by the database). Using matchers, we can verify that the the response returns any value, or returns values that follow some sort of pattern or structure. Here is an example test that makes heavy use of various matchers: @@ -14,15 +14,15 @@ tests: expect: status: 2xx # match any 200 level status code headers: - Content-Type: application/json # exact match + Content-Type: $regex /(json)+/ # match the provided regular expression body: - message: $regex /[Hh]ello/ # match based on regex + message: $length >= 1 # ensure the message is not an empty string currentTime: $exists # match anything as long as it is present ``` ## Format -Matchers are always a keyword prefixed with a `$` symbol, and followed by any number of arguments. The arguments, in some case, are also valid as a matcher themselves, thus some matchers can be nested. Here are some examples: +Matchers are always a keyword prefixed with a `$` symbol, and followed by any number of arguments. The arguments, in some case, are also valid as matchers themselves, thus some matchers can be nested. Here are some examples: ``` # this matcher takes no arguments @@ -31,7 +31,8 @@ $exists # this matcher takes one argument - a regex string used to match the response $regex /[Hh]ello/ -# this matcher checks whether the array at this position contains an object with the property "id" +# this matcher checks whether the array at this position +# contains an object with the property "id" $includes { "id": $exists } # includes can also check any other valid YAML/JSON object, value, or matcher diff --git a/docs/src/matchers/not.md b/docs/src/matchers/not.md new file mode 100644 index 0000000..5cae3bb --- /dev/null +++ b/docs/src/matchers/not.md @@ -0,0 +1,44 @@ +# $not + +The `$not` matcher simply matches the opposite of the provided matcher or value. + +## Examples + +```yaml + - test: Recipe not guacamole + description: This recipe should not be named 'Guacamole' + request: + method: GET + url: ${BASE_URL}/recipes/${RECIPE_ID} + expect: + body: + title: $not Guacamole +``` + +The `$not` matcher can match other matchers as well. This example uses a [`$regex` matcher](./regex.md) to ensure that a field in the response does not contain any quotation marks. + +```yaml + - test: No quotes in name + description: The recipe title should not have quotes in the name + request: + method: GET + url: ${BASE_URL}/recipes/${RECIPE_ID} + expect: + body: + title: $not $regex /\"+/ +``` + +In this more complex example, the `$not` matcher negates an [`$includes` matcher](./includes.md) to confirm that an object containing the specified id does not appear in the array. + +```yaml + - test: Deleted recipe gone + description: The now-deleted recipe should no longer appear in the list of recipes + request: + method: GET + url: ${BASE_URL}/recipes/all + expect: + status: 2xx + body: + data: '$not $includes { "id": "${RECIPE_ID}" }' +``` + diff --git a/docs/src/variables.md b/docs/src/variables.md index ffe687e..82c6b80 100644 --- a/docs/src/variables.md +++ b/docs/src/variables.md @@ -22,5 +22,6 @@ tests: url: ${BASE_URL}/recipes expect: status: 2xx - body: $length >= 1 -``` \ No newline at end of file + body: + data: $length >= 1 +``` diff --git a/src/m_value/matcher_definition.rs b/src/m_value/matcher_definition.rs index 69993c3..9a75236 100644 --- a/src/m_value/matcher_definition.rs +++ b/src/m_value/matcher_definition.rs @@ -5,6 +5,7 @@ use serde::Serialize; use crate::{ errors::CaptiError, + m_value::mvalue_wrapper::MValueWrapper, variables::{variable_map::VariableMap, SuiteVariables}, }; @@ -81,7 +82,7 @@ impl TryFrom<&str> for MatcherDefinition { if let Some(key_candidate) = parts.next() { if let Some(_) = MatcherMap::get_matcher(key_candidate) { let args = parts.map(|s| s.into()).collect::>().join(" "); - let args = serde_yaml::from_str::(&args).unwrap_or(MValue::String(args)); + let args = MValueWrapper::from_json_value(&args); return Ok(MatcherDefinition { match_key: key_candidate.to_string(), args, diff --git a/src/m_value/matcher_map.rs b/src/m_value/matcher_map.rs index 33a62b1..eff5a73 100644 --- a/src/m_value/matcher_map.rs +++ b/src/m_value/matcher_map.rs @@ -23,6 +23,7 @@ impl MatcherMap { map.insert_mp(Empty::new()); map.insert_mp(Includes::new()); map.insert_mp(Length::new()); + map.insert_mp(Not::new()); map } diff --git a/src/m_value/mod.rs b/src/m_value/mod.rs index db847d0..e7c7742 100644 --- a/src/m_value/mod.rs +++ b/src/m_value/mod.rs @@ -6,5 +6,6 @@ pub mod match_context; pub mod match_processor; pub mod matcher_definition; pub mod matcher_map; +pub mod mvalue_wrapper; pub mod status_matcher; pub mod std_matchers; diff --git a/src/m_value/mvalue_wrapper.rs b/src/m_value/mvalue_wrapper.rs new file mode 100644 index 0000000..b412a2b --- /dev/null +++ b/src/m_value/mvalue_wrapper.rs @@ -0,0 +1,36 @@ +use serde::{Deserialize, Serialize}; + +use super::m_value::MValue; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct MValueWrapper { + key: MValue, +} + +impl MValueWrapper { + /// attempts to first parse string as a yaml value (not a key) and then try a JSON value if YAML + /// does not succeed. If both fail, the value is treated as a string. + pub fn from_yaml_value(value: &str) -> MValue { + let yaml_value = format!("key: >\n {}", value); + let json_value = format!(r#"{{ "key": "{}" }}"#, value); + let wrapper = serde_yaml::from_str::(&yaml_value).unwrap_or_else(|_| { + serde_json::from_str::(&json_value).unwrap_or(MValueWrapper { + key: MValue::String(value.to_string()), + }) + }); + return wrapper.key; + } + + /// attempts to first parse string as a JSON value and then try a YAML value if JSON does not + /// succeed. If both fail, the value is treated as a string. + pub fn from_json_value(value: &str) -> MValue { + serde_json::from_str::(value).unwrap_or_else(|_| { + let yaml_value = format!("key: >\n {}", value); + let wrapper = + serde_yaml::from_str::(&yaml_value).unwrap_or(MValueWrapper { + key: MValue::String(value.to_string()), + }); + wrapper.key + }) + } +} diff --git a/src/m_value/std_matchers/mod.rs b/src/m_value/std_matchers/mod.rs index d7a6e0e..0c3ce09 100644 --- a/src/m_value/std_matchers/mod.rs +++ b/src/m_value/std_matchers/mod.rs @@ -3,6 +3,7 @@ pub mod empty; pub mod exists; pub mod includes; pub mod length; +pub mod not; pub mod regex; pub use absent::Absent; @@ -10,4 +11,5 @@ pub use empty::Empty; pub use exists::Exists; pub use includes::Includes; pub use length::Length; +pub use not::Not; pub use regex::Regex; diff --git a/src/m_value/std_matchers/not.rs b/src/m_value/std_matchers/not.rs new file mode 100644 index 0000000..bc43683 --- /dev/null +++ b/src/m_value/std_matchers/not.rs @@ -0,0 +1,23 @@ +use crate::m_value::{m_match::MMatch, m_value::MValue, match_processor::MatchProcessor}; + +// The $not matcher matches the opposite of the given arguments +pub struct Not; + +impl Not { + pub fn new() -> Box { + Box::new(Not) + } +} + +impl MatchProcessor for Not { + fn key(&self) -> String { + String::from("$not") + } + + fn is_match(&self, args: &MValue, value: &MValue) -> bool { + match args.matches(value) { + true => false, + false => true, + } + } +} diff --git a/test_app/tests/recipe_create.yaml b/test_app/tests/recipe_create.yaml index 8c0c1c4..7892967 100644 --- a/test_app/tests/recipe_create.yaml +++ b/test_app/tests/recipe_create.yaml @@ -87,6 +87,43 @@ tests: status: 2xx body: ${RECIPE} + - test: Recipe name not incorrect + description: This recipe should be named 'Guacamole' + should_fail: true + request: + method: GET + url: ${BASE_URL}/recipes/${RECIPE_ID} + expect: + body: + name: $not Guacamole + + + - test: Get recipe has id + description: 'Should be able to get recipe information' + request: + method: GET + url: ${BASE_URL}/recipes/${RECIPE_ID} + expect: + status: 2xx + body: '$not $not { "id": "${RECIPE_ID}" }' + + - test: Recipe list has recipe with id + request: + method: GET + url: ${BASE_URL}/recipes + expect: + status: 2xx + body: '$includes { "id": "${RECIPE_ID}" }' + + - test: Recipe list not empty + description: Recipe list should not be empty + request: + method: GET + url: ${BASE_URL}/recipes + expect: + status: 2xx + body: $not $empty + - test: Recipe in list description: Recipe should be visible when listing all recipes request: From b4478d962c8e03cdad1d0c1294b21c9f6c8c6a9a Mon Sep 17 00:00:00 2001 From: Wvaviator Date: Mon, 19 Feb 2024 21:03:09 -0600 Subject: [PATCH 32/34] add contributing docs --- docs/src/contributing.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/src/contributing.md b/docs/src/contributing.md index 854139a..5c874d2 100644 --- a/docs/src/contributing.md +++ b/docs/src/contributing.md @@ -1 +1,19 @@ # Contributing + +If you're interested in contributing to Capti, please reach out! I'd love to collaborate on new ideas and the future of Capti tests. Feel free to help with any of the below items, or by [creating an issue](./reporting_issues.md). + +## Development + +If you're even just a little familiar with Rust, I'd love your help in developing components of Capti and fixing bugs. Especially if you are experienced in working with serde or reqwest. + +Additionally, if you'd like to help write some Node/Express/TypeScript code for the test app so that more scenarios can be used for examples and testing, that would be a huge help. + +The future of Capti may involve elements such as custom LSPs, syntax highlighting for YAML files, VSCode extensions, etc. If you have experience with any of these, I would appreciate your help. + +## Testing + +Want to help test Capti? If you happen to have a few REST APIs laying around that you've developed over the years, use Capti to create some tests for them. Try testing various use cases and see if you come across any limitations or issues. + +## Feature Suggestions + +Have an idea that might improve Capti? I'm all ears. I have a lot of ideas I'd like to implement in the future, but I'm always open to new and thoughtful suggestions - especially from users of the framework. From 7bd926fcbc11d7112c4c027e5dd9bdd7d622b0e4 Mon Sep 17 00:00:00 2001 From: Wvaviator Date: Tue, 20 Feb 2024 18:09:02 -0600 Subject: [PATCH 33/34] add github workflow for docs deployment --- .github/workflows/mdbook.yaml | 39 +++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/mdbook.yaml diff --git a/.github/workflows/mdbook.yaml b/.github/workflows/mdbook.yaml new file mode 100644 index 0000000..a3bfdff --- /dev/null +++ b/.github/workflows/mdbook.yaml @@ -0,0 +1,39 @@ +name: Deploy + +on: + workflow_dispatch: + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + contents: write + pages: write + id-token: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install latest mdbook + run: | + tag=$(curl 'https://api.github.com/repos/rust-lang/mdbook/releases/latest' | jq -r '.tag_name') + url="https://github.com/rust-lang/mdbook/releases/download/${tag}/mdbook-${tag}-x86_64-unknown-linux-gnu.tar.gz" + mkdir mdbook + curl -sSL $url | tar -xz --directory=./mdbook + echo `pwd`/mdbook >> $GITHUB_PATH + - name: Build Book + run: | + cd docs + mdbook build + - name: Setup Pages + uses: actions/configure-pages@v2 + - name: Upload artifact + uses: actions/upload-pages-artifact@v1 + with: + path: 'book' + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v1 From c48622a4c8a11f2bf467a79f16a2a30ead1e9fe6 Mon Sep 17 00:00:00 2001 From: Wvaviator Date: Tue, 20 Feb 2024 18:11:25 -0600 Subject: [PATCH 34/34] bump version --- Cargo.lock | 2 +- Cargo.toml | 2 +- npm/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 25e9844..709541d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -127,7 +127,7 @@ checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" [[package]] name = "capti" -version = "0.0.14" +version = "0.0.15" dependencies = [ "clap", "colored", diff --git a/Cargo.toml b/Cargo.toml index 0af8b81..7cc4eb5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "capti" -version = "0.0.14" +version = "0.0.15" edition = "2021" [dependencies] diff --git a/npm/package.json b/npm/package.json index 1f606d3..bbee33e 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "capti", - "version": "0.0.14", + "version": "0.0.15", "description": "Capti is a comprehensive end-to-end REST API testing framework.", "keywords": [ "testing",