diff --git a/package-lock.json b/package-lock.json index f07c2b7fb..3c7d62865 100644 --- a/package-lock.json +++ b/package-lock.json @@ -101,6 +101,7 @@ "@playwright/test": "~1.39.0", "@popperjs/core": "~2.10.2", "@testcontainers/hivemq": "^10.13.1", + "@testcontainers/mariadb": "^10.13.0", "@testcontainers/mssqlserver": "^10.28.0", "@testcontainers/postgresql": "^11.9.0", "@testcontainers/rabbitmq": "^10.13.2", @@ -5645,6 +5646,15 @@ "testcontainers": "^10.28.0" } }, + "node_modules/@testcontainers/mariadb": { + "version": "10.28.0", + "resolved": "https://registry.npmjs.org/@testcontainers/mariadb/-/mariadb-10.28.0.tgz", + "integrity": "sha512-+ETpRbHOWxEj6uwMfhTVvE6ap0U+olD+v8XbAE2+88YgsHzlmfWWi/EXsOfW1VZsWblYE5kR0k1O//a9Sei4Mg==", + "dev": true, + "dependencies": { + "testcontainers": "^10.28.0" + } + }, "node_modules/@testcontainers/mssqlserver": { "version": "10.28.0", "resolved": "https://registry.npmjs.org/@testcontainers/mssqlserver/-/mssqlserver-10.28.0.tgz", diff --git a/package.json b/package.json index 1fff55628..e463b19c7 100644 --- a/package.json +++ b/package.json @@ -162,6 +162,7 @@ "@playwright/test": "~1.39.0", "@popperjs/core": "~2.10.2", "@testcontainers/hivemq": "^10.13.1", + "@testcontainers/mariadb": "^10.13.0", "@testcontainers/mssqlserver": "^10.28.0", "@testcontainers/postgresql": "^11.9.0", "@testcontainers/rabbitmq": "^10.13.2", diff --git a/server/model/monitor.js b/server/model/monitor.js index ed4bb219c..dac165072 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -8,7 +8,7 @@ const { log, UP, DOWN, PENDING, MAINTENANCE, flipStatus, MAX_INTERVAL_SECOND, MI PING_COUNT_MIN, PING_COUNT_MAX, PING_COUNT_DEFAULT, PING_PER_REQUEST_TIMEOUT_MIN, PING_PER_REQUEST_TIMEOUT_MAX, PING_PER_REQUEST_TIMEOUT_DEFAULT } = require("../../src/util"); -const { ping, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mysqlQuery, setSetting, httpNtlm, radius, +const { ping, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, setSetting, httpNtlm, radius, kafkaProducerAsync, getOidcTokenClientCredentials, rootCertificatesFingerprints, axiosAbortSignal, checkCertificateHostname } = require("../util-server"); const { R } = require("redbean-node"); @@ -781,16 +781,6 @@ class Monitor extends BeanModel { bean.status = UP; bean.msg = `Container has not reported health and is currently ${res.data.State.Status}. As it is running, it is considered UP. Consider adding a health check for better service visibility`; } - } else if (this.type === "mysql") { - let startTime = dayjs().valueOf(); - - // Use `radius_password` as `password` field, since there are too many unnecessary fields - // TODO: rename `radius_password` to `password` later for general use - let mysqlPassword = this.radiusPassword; - - bean.msg = await mysqlQuery(this.databaseConnectionString, this.databaseQuery || "SELECT 1", mysqlPassword); - bean.status = UP; - bean.ping = dayjs().valueOf() - startTime; } else if (this.type === "radius") { let startTime = dayjs().valueOf(); diff --git a/server/monitor-types/mssql.js b/server/monitor-types/mssql.js index 40385351f..1f4284aa2 100644 --- a/server/monitor-types/mssql.js +++ b/server/monitor-types/mssql.js @@ -21,53 +21,92 @@ class MssqlMonitorType extends MonitorType { * @inheritdoc */ async check(monitor, heartbeat, _server) { - let startTime = dayjs().valueOf(); - let query = monitor.databaseQuery; // No query provided by user, use SELECT 1 if (!query || (typeof query === "string" && query.trim() === "")) { query = "SELECT 1"; } - let result; + const conditions = monitor.conditions ? ConditionExpressionGroup.fromMonitor(monitor) : null; + const hasConditions = conditions && conditions.children && conditions.children.length > 0; + + const startTime = dayjs().valueOf(); try { - result = await this.mssqlQuery( - monitor.databaseConnectionString, - query - ); + if (hasConditions) { + // When conditions are enabled, expect a single value result + const result = await this.mssqlQuerySingleValue( + monitor.databaseConnectionString, + query + ); + heartbeat.ping = dayjs().valueOf() - startTime; + + const conditionsResult = evaluateExpressionGroup(conditions, { result: String(result) }); + + if (!conditionsResult) { + throw new Error(`Query result did not meet the specified conditions (${result})`); + } + + heartbeat.status = UP; + heartbeat.msg = "Query did meet specified conditions"; + } else { + // Backwards compatible: just check connection and return row count + const result = await this.mssqlQuery( + monitor.databaseConnectionString, + query + ); + heartbeat.ping = dayjs().valueOf() - startTime; + heartbeat.status = UP; + heartbeat.msg = result; + } } catch (error) { - log.error("sqlserver", "Database query failed:", error.message); - throw new Error( - `Database connection/query failed: ${error.message}` - ); - } finally { heartbeat.ping = dayjs().valueOf() - startTime; + // Re-throw condition errors as-is, wrap database errors + if (error.message.includes("did not meet the specified conditions")) { + throw error; + } + throw new Error(`Database connection/query failed: ${error.message}`); } - - const conditions = ConditionExpressionGroup.fromMonitor(monitor); - const handleConditions = (data) => - conditions ? evaluateExpressionGroup(conditions, data) : true; - - // Since result is now a single value, pass it directly to conditions - const conditionsResult = handleConditions({ result: String(result) }); - - if (!conditionsResult) { - throw new Error( - `Query result did not meet the specified conditions (${result})` - ); - } - - heartbeat.msg = ""; - heartbeat.status = UP; } /** - * Run a query on MSSQL server + * Run a query on MSSQL server (backwards compatible - returns row count) + * @param {string} connectionString The database connection string + * @param {string} query The query to validate the database with + * @returns {Promise} Row count message + */ + async mssqlQuery(connectionString, query) { + let pool; + try { + pool = new mssql.ConnectionPool(connectionString); + await pool.connect(); + const result = await pool.request().query(query); + + if (result.recordset) { + return "Rows: " + result.recordset.length; + } else { + return "No Error, but the result is not an array. Type: " + typeof result.recordset; + } + } catch (err) { + log.debug( + "sqlserver", + "Error caught in the query execution.", + err.message + ); + throw err; + } finally { + if (pool) { + await pool.close(); + } + } + } + + /** + * Run a query on MSSQL server expecting a single value result * @param {string} connectionString The database connection string * @param {string} query The query to validate the database with * @returns {Promise} Single value from the first column of the first row */ - async mssqlQuery(connectionString, query) { + async mssqlQuerySingleValue(connectionString, query) { let pool; try { pool = new mssql.ConnectionPool(connectionString); diff --git a/server/monitor-types/mysql.js b/server/monitor-types/mysql.js new file mode 100644 index 000000000..508745e1c --- /dev/null +++ b/server/monitor-types/mysql.js @@ -0,0 +1,165 @@ +const { MonitorType } = require("./monitor-type"); +const { UP } = require("../../src/util"); +const dayjs = require("dayjs"); +const mysql = require("mysql2"); +const { ConditionVariable } = require("../monitor-conditions/variables"); +const { defaultStringOperators } = require("../monitor-conditions/operators"); +const { ConditionExpressionGroup } = require("../monitor-conditions/expression"); +const { evaluateExpressionGroup } = require("../monitor-conditions/evaluator"); + +class MysqlMonitorType extends MonitorType { + name = "mysql"; + + supportsConditions = true; + conditionVariables = [ + new ConditionVariable("result", defaultStringOperators), + ]; + + /** + * @inheritdoc + */ + async check(monitor, heartbeat, _server) { + let query = monitor.databaseQuery; + if (!query || (typeof query === "string" && query.trim() === "")) { + query = "SELECT 1"; + } + + // Use `radius_password` as `password` field for backwards compatibility + // TODO: rename `radius_password` to `password` later for general use + const password = monitor.radiusPassword; + + const conditions = monitor.conditions ? ConditionExpressionGroup.fromMonitor(monitor) : null; + const hasConditions = conditions && conditions.children && conditions.children.length > 0; + + const startTime = dayjs().valueOf(); + try { + if (hasConditions) { + // When conditions are enabled, expect a single value result + const result = await this.mysqlQuerySingleValue(monitor.databaseConnectionString, query, password); + heartbeat.ping = dayjs().valueOf() - startTime; + + const conditionsResult = evaluateExpressionGroup(conditions, { result: String(result) }); + + if (!conditionsResult) { + throw new Error(`Query result did not meet the specified conditions (${result})`); + } + + heartbeat.status = UP; + heartbeat.msg = "Query did meet specified conditions"; + } else { + // Backwards compatible: just check connection and return row count + const result = await this.mysqlQuery(monitor.databaseConnectionString, query, password); + heartbeat.ping = dayjs().valueOf() - startTime; + heartbeat.status = UP; + heartbeat.msg = result; + } + } catch (error) { + heartbeat.ping = dayjs().valueOf() - startTime; + // Re-throw condition errors as-is, wrap database errors + if (error.message.includes("did not meet the specified conditions")) { + throw error; + } + throw new Error(`Database connection/query failed: ${error.message}`); + } + } + + /** + * Run a query on MySQL/MariaDB (backwards compatible - returns row count) + * @param {string} connectionString The database connection string + * @param {string} query The query to execute + * @param {string} password Optional password override + * @returns {Promise} Row count message + */ + mysqlQuery(connectionString, query, password = undefined) { + return new Promise((resolve, reject) => { + const connection = mysql.createConnection({ + uri: connectionString, + password + }); + + connection.on("error", (err) => { + reject(err); + }); + + connection.query(query, (err, res) => { + try { + connection.end(); + } catch (_) { + connection.destroy(); + } + + if (err) { + reject(err); + return; + } + + if (Array.isArray(res)) { + resolve("Rows: " + res.length); + } else { + resolve("No Error, but the result is not an array. Type: " + typeof res); + } + }); + }); + } + + /** + * Run a query on MySQL/MariaDB expecting a single value result + * @param {string} connectionString The database connection string + * @param {string} query The query to execute + * @param {string} password Optional password override + * @returns {Promise} Single value from the first column of the first row + */ + mysqlQuerySingleValue(connectionString, query, password = undefined) { + return new Promise((resolve, reject) => { + const connection = mysql.createConnection({ + uri: connectionString, + password + }); + + connection.on("error", (err) => { + reject(err); + }); + + connection.query(query, (err, res) => { + try { + connection.end(); + } catch (_) { + connection.destroy(); + } + + if (err) { + reject(err); + return; + } + + // Check if we have results + if (!Array.isArray(res) || res.length === 0) { + reject(new Error("Query returned no results")); + return; + } + + // Check if we have multiple rows + if (res.length > 1) { + reject(new Error("Multiple values were found, expected only one value")); + return; + } + + const firstRow = res[0]; + const columnNames = Object.keys(firstRow); + + // Check if we have multiple columns + if (columnNames.length > 1) { + reject(new Error("Multiple columns were found, expected only one value")); + return; + } + + // Return the single value from the first (and only) column + resolve(firstRow[columnNames[0]]); + }); + }); + } +} + +module.exports = { + MysqlMonitorType, +}; diff --git a/server/uptime-kuma-server.js b/server/uptime-kuma-server.js index f813f8e80..5739d268e 100644 --- a/server/uptime-kuma-server.js +++ b/server/uptime-kuma-server.js @@ -128,6 +128,7 @@ class UptimeKumaServer { UptimeKumaServer.monitorTypeList["redis"] = new RedisMonitorType(); UptimeKumaServer.monitorTypeList["system-service"] = new SystemServiceMonitorType(); UptimeKumaServer.monitorTypeList["sqlserver"] = new MssqlMonitorType(); + UptimeKumaServer.monitorTypeList["mysql"] = new MysqlMonitorType(); // Allow all CORS origins (polling) in development let cors = undefined; @@ -580,5 +581,6 @@ const { ManualMonitorType } = require("./monitor-types/manual"); const { RedisMonitorType } = require("./monitor-types/redis"); const { SystemServiceMonitorType } = require("./monitor-types/system-service"); const { MssqlMonitorType } = require("./monitor-types/mssql"); +const { MysqlMonitorType } = require("./monitor-types/mysql"); const Monitor = require("./model/monitor"); diff --git a/server/util-server.js b/server/util-server.js index 552ae6a21..f87965da1 100644 --- a/server/util-server.js +++ b/server/util-server.js @@ -10,7 +10,6 @@ const { Resolver } = require("dns"); const iconv = require("iconv-lite"); const chardet = require("chardet"); const chroma = require("chroma-js"); -const mysql = require("mysql2"); const { NtlmClient } = require("./modules/axios-ntlm/lib/ntlmClient.js"); const { Settings } = require("./settings"); const RadiusClient = require("./radius-client"); @@ -321,44 +320,6 @@ exports.dnsResolve = function (hostname, resolverServer, resolverPort, rrtype) { }); }; -/** - * Run a query on MySQL/MariaDB - * @param {string} connectionString The database connection string - * @param {string} query The query to validate the database with - * @param {?string} password The password to use - * @returns {Promise<(string)>} Response from server - */ -exports.mysqlQuery = function (connectionString, query, password = undefined) { - return new Promise((resolve, reject) => { - const connection = mysql.createConnection({ - uri: connectionString, - password - }); - - connection.on("error", (err) => { - reject(err); - }); - - connection.query(query, (err, res) => { - if (err) { - reject(err); - } else { - if (Array.isArray(res)) { - resolve("Rows: " + res.length); - } else { - resolve("No Error, but the result is not an array. Type: " + typeof res); - } - } - - try { - connection.end(); - } catch (_) { - connection.destroy(); - } - }); - }); -}; - /** * Query radius server * @param {string} hostname Hostname of radius server diff --git a/test/backend-test/monitors/test-mssql.js b/test/backend-test/monitors/test-mssql.js index f265bcdff..37c050682 100644 --- a/test/backend-test/monitors/test-mssql.js +++ b/test/backend-test/monitors/test-mssql.js @@ -6,20 +6,25 @@ const { UP, PENDING } = require("../../../src/util"); /** * Helper function to create and start a MSSQL container - * @returns {Promise} The started MSSQL container + * @returns {Promise<{container: MSSQLServerContainer, connectionString: string}>} The started container and connection string */ async function createAndStartMSSQLContainer() { - return await new MSSQLServerContainer( + const container = await new MSSQLServerContainer( "mcr.microsoft.com/mssql/server:2022-latest" ) .acceptLicense() // The default timeout of 30 seconds might not be enough for the container to start .withStartupTimeout(60000) .start(); + + return { + container, + connectionString: container.getConnectionUri(false) + }; } describe( - "MSSQL Single Node", + "MSSQL Monitor", { skip: !!process.env.CI && @@ -27,55 +32,63 @@ describe( }, () => { test("check() sets status to UP when MSSQL server is reachable", async () => { - let mssqlContainer; + const { container, connectionString } = await createAndStartMSSQLContainer(); + + const mssqlMonitor = new MssqlMonitorType(); + const monitor = { + databaseConnectionString: connectionString, + conditions: "[]", + }; + + const heartbeat = { + msg: "", + status: PENDING, + }; try { - mssqlContainer = await createAndStartMSSQLContainer(); - - const mssqlMonitor = new MssqlMonitorType(); - const monitor = { - databaseConnectionString: - mssqlContainer.getConnectionUri(false), - conditions: "[]", - }; - - const heartbeat = { - msg: "", - status: PENDING, - }; - await mssqlMonitor.check(monitor, heartbeat, {}); assert.strictEqual( heartbeat.status, UP, `Expected status ${UP} but got ${heartbeat.status}` ); - } catch (error) { - console.error("Test failed with error:", error.message); - console.error("Error stack:", error.stack); - if (mssqlContainer) { - console.error("Container ID:", mssqlContainer.getId()); - console.error( - "Container logs:", - await mssqlContainer.logs() - ); - } - throw error; } finally { - if (mssqlContainer) { - console.log("Stopping MSSQL container..."); - await mssqlContainer.stop(); - } + await container.stop(); } }); - test("check() sets status to UP when custom query returns single value", async () => { - const mssqlContainer = await createAndStartMSSQLContainer(); - + test("check() rejects when MSSQL server is not reachable", async () => { const mssqlMonitor = new MssqlMonitorType(); const monitor = { databaseConnectionString: - mssqlContainer.getConnectionUri(false), + "Server=localhost,15433;Database=master;User Id=Fail;Password=Fail;Encrypt=false", + conditions: "[]", + }; + + const heartbeat = { + msg: "", + status: PENDING, + }; + + await assert.rejects( + mssqlMonitor.check(monitor, heartbeat, {}), + new Error( + "Database connection/query failed: Failed to connect to localhost:15433 - Could not connect (sequence)" + ) + ); + assert.notStrictEqual( + heartbeat.status, + UP, + `Expected status should not be ${heartbeat.status}` + ); + }); + + test("check() sets status to UP when custom query returns single value", async () => { + const { container, connectionString } = await createAndStartMSSQLContainer(); + + const mssqlMonitor = new MssqlMonitorType(); + const monitor = { + databaseConnectionString: connectionString, databaseQuery: "SELECT 42", conditions: "[]", }; @@ -93,18 +106,17 @@ describe( `Expected status ${UP} but got ${heartbeat.status}` ); } finally { - await mssqlContainer.stop(); + await container.stop(); } }); test("check() sets status to UP when custom query result meets condition", async () => { - const mssqlContainer = await createAndStartMSSQLContainer(); + const { container, connectionString } = await createAndStartMSSQLContainer(); const mssqlMonitor = new MssqlMonitorType(); const monitor = { - databaseConnectionString: - mssqlContainer.getConnectionUri(false), - databaseQuery: "SELECT 42 as value", + databaseConnectionString: connectionString, + databaseQuery: "SELECT 42 AS value", conditions: JSON.stringify([ { type: "expression", @@ -129,18 +141,17 @@ describe( `Expected status ${UP} but got ${heartbeat.status}` ); } finally { - await mssqlContainer.stop(); + await container.stop(); } }); test("check() rejects when custom query result does not meet condition", async () => { - const mssqlContainer = await createAndStartMSSQLContainer(); + const { container, connectionString } = await createAndStartMSSQLContainer(); const mssqlMonitor = new MssqlMonitorType(); const monitor = { - databaseConnectionString: - mssqlContainer.getConnectionUri(false), - databaseQuery: "SELECT 99 as value", + databaseConnectionString: connectionString, + databaseQuery: "SELECT 99 AS value", conditions: JSON.stringify([ { type: "expression", @@ -170,19 +181,26 @@ describe( `Expected status should not be ${heartbeat.status}` ); } finally { - await mssqlContainer.stop(); + await container.stop(); } }); - test("check() rejects when query returns no results", async () => { - const mssqlContainer = await createAndStartMSSQLContainer(); + test("check() rejects when query returns no results with conditions", async () => { + const { container, connectionString } = await createAndStartMSSQLContainer(); const mssqlMonitor = new MssqlMonitorType(); const monitor = { - databaseConnectionString: - mssqlContainer.getConnectionUri(false), + databaseConnectionString: connectionString, databaseQuery: "SELECT 1 WHERE 1 = 0", - conditions: "[]", + conditions: JSON.stringify([ + { + type: "expression", + andOr: "and", + variable: "result", + operator: "equals", + value: "1", + }, + ]), }; const heartbeat = { @@ -203,19 +221,26 @@ describe( `Expected status should not be ${heartbeat.status}` ); } finally { - await mssqlContainer.stop(); + await container.stop(); } }); - test("check() rejects when query returns multiple rows", async () => { - const mssqlContainer = await createAndStartMSSQLContainer(); + test("check() rejects when query returns multiple rows with conditions", async () => { + const { container, connectionString } = await createAndStartMSSQLContainer(); const mssqlMonitor = new MssqlMonitorType(); const monitor = { - databaseConnectionString: - mssqlContainer.getConnectionUri(false), + databaseConnectionString: connectionString, databaseQuery: "SELECT 1 UNION ALL SELECT 2", - conditions: "[]", + conditions: JSON.stringify([ + { + type: "expression", + andOr: "and", + variable: "result", + operator: "equals", + value: "1", + }, + ]), }; const heartbeat = { @@ -236,19 +261,26 @@ describe( `Expected status should not be ${heartbeat.status}` ); } finally { - await mssqlContainer.stop(); + await container.stop(); } }); - test("check() rejects when query returns multiple columns", async () => { - const mssqlContainer = await createAndStartMSSQLContainer(); + test("check() rejects when query returns multiple columns with conditions", async () => { + const { container, connectionString } = await createAndStartMSSQLContainer(); const mssqlMonitor = new MssqlMonitorType(); const monitor = { - databaseConnectionString: - mssqlContainer.getConnectionUri(false), + databaseConnectionString: connectionString, databaseQuery: "SELECT 1 AS col1, 2 AS col2", - conditions: "[]", + conditions: JSON.stringify([ + { + type: "expression", + andOr: "and", + variable: "result", + operator: "equals", + value: "1", + }, + ]), }; const heartbeat = { @@ -269,34 +301,8 @@ describe( `Expected status should not be ${heartbeat.status}` ); } finally { - await mssqlContainer.stop(); + await container.stop(); } }); - - test("check() rejects when MSSQL server is not reachable", async () => { - const mssqlMonitor = new MssqlMonitorType(); - const monitor = { - databaseConnectionString: - "Server=localhost,15433;Database=master;User Id=Fail;Password=Fail;Encrypt=false", - conditions: "[]", - }; - - const heartbeat = { - msg: "", - status: PENDING, - }; - - await assert.rejects( - mssqlMonitor.check(monitor, heartbeat, {}), - new Error( - "Database connection/query failed: Failed to connect to localhost:15433 - Could not connect (sequence)" - ) - ); - assert.notStrictEqual( - heartbeat.status, - UP, - `Expected status should not be ${heartbeat.status}` - ); - }); } ); diff --git a/test/backend-test/monitors/test-mysql.js b/test/backend-test/monitors/test-mysql.js new file mode 100644 index 000000000..d14a6a1a6 --- /dev/null +++ b/test/backend-test/monitors/test-mysql.js @@ -0,0 +1,163 @@ +const { describe, test } = require("node:test"); +const assert = require("node:assert"); +const { MariaDbContainer } = require("@testcontainers/mariadb"); +const { MysqlMonitorType } = require("../../../server/monitor-types/mysql"); +const { UP, PENDING } = require("../../../src/util"); + +/** + * Helper function to create and start a MariaDB container + * @returns {Promise<{container: MariaDbContainer, connectionString: string}>} The started container and connection string + */ +async function createAndStartMariaDBContainer() { + const container = await new MariaDbContainer("mariadb:10.11") + .withStartupTimeout(90000) + .start(); + + const connectionString = `mysql://${container.getUsername()}:${container.getUserPassword()}@${container.getHost()}:${container.getPort()}/${container.getDatabase()}`; + + return { + container, + connectionString + }; +} + +describe( + "MySQL/MariaDB Monitor", + { + skip: + !!process.env.CI && + (process.platform !== "linux" || process.arch !== "x64"), + }, + () => { + test("check() sets status to UP when MariaDB server is reachable", async () => { + const { container, connectionString } = await createAndStartMariaDBContainer(); + + const mysqlMonitor = new MysqlMonitorType(); + const monitor = { + databaseConnectionString: connectionString, + conditions: "[]", + }; + + const heartbeat = { + msg: "", + status: PENDING, + }; + + try { + await mysqlMonitor.check(monitor, heartbeat, {}); + assert.strictEqual( + heartbeat.status, + UP, + `Expected status ${UP} but got ${heartbeat.status}` + ); + } finally { + await container.stop(); + } + }); + + test("check() rejects when MariaDB server is not reachable", async () => { + const mysqlMonitor = new MysqlMonitorType(); + const monitor = { + databaseConnectionString: + "mysql://invalid:invalid@localhost:13306/test", + conditions: "[]", + }; + + const heartbeat = { + msg: "", + status: PENDING, + }; + + await assert.rejects( + mysqlMonitor.check(monitor, heartbeat, {}), + (err) => { + assert.ok( + err.message.includes("Database connection/query failed"), + `Expected error message to include "Database connection/query failed" but got: ${err.message}` + ); + return true; + } + ); + assert.notStrictEqual( + heartbeat.status, + UP, + `Expected status should not be ${UP}` + ); + }); + + test("check() sets status to UP when custom query result meets condition", async () => { + const { container, connectionString } = await createAndStartMariaDBContainer(); + + const mysqlMonitor = new MysqlMonitorType(); + const monitor = { + databaseConnectionString: connectionString, + databaseQuery: "SELECT 42 AS value", + conditions: JSON.stringify([ + { + type: "expression", + andOr: "and", + variable: "result", + operator: "equals", + value: "42", + }, + ]), + }; + + const heartbeat = { + msg: "", + status: PENDING, + }; + + try { + await mysqlMonitor.check(monitor, heartbeat, {}); + assert.strictEqual( + heartbeat.status, + UP, + `Expected status ${UP} but got ${heartbeat.status}` + ); + } finally { + await container.stop(); + } + }); + + test("check() rejects when custom query result does not meet condition", async () => { + const { container, connectionString } = await createAndStartMariaDBContainer(); + + const mysqlMonitor = new MysqlMonitorType(); + const monitor = { + databaseConnectionString: connectionString, + databaseQuery: "SELECT 99 AS value", + conditions: JSON.stringify([ + { + type: "expression", + andOr: "and", + variable: "result", + operator: "equals", + value: "42", + }, + ]), + }; + + const heartbeat = { + msg: "", + status: PENDING, + }; + + try { + await assert.rejects( + mysqlMonitor.check(monitor, heartbeat, {}), + new Error( + "Query result did not meet the specified conditions (99)" + ) + ); + assert.strictEqual( + heartbeat.status, + PENDING, + `Expected status should not be ${heartbeat.status}` + ); + } finally { + await container.stop(); + } + }); + } +);