Repository: hyperjumptech/monika
Pull request #618 (2022-02-22 11:15:20)

added: add tcp request
# Monika Pull Request (PR)  

## What feature/issue does this PR add  
1. Add TCP support

## How did you implement / how did you fix it  
1. Create function to validate the new TCP config
2. Add function to send TCP request
3. Process the TCP response

## How to test  
1. Run TCP server on port 3333
2. Run Monika use the configuration as below
```yaml
probes:
  - id: "random-id"
    socket:
      host: localhost
      port: 3333
      data: Hello from Monika
```

## Notes
1. Timeout, alert query and alert message still hardcoded.

## Videos

https://user-images.githubusercontent.com/15191978/155123103-43b63370-236e-427c-868d-f95832dc8d74.mp4




codecov[bot]: # [Codecov](https://codecov.io/gh/hyperjumptech/monika/pull/618?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperjumptech) Report
> Merging [#618](https://codecov.io/gh/hyperjumptech/monika/pull/618?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperjumptech) (08e1595) into [main](https://codecov.io/gh/hyperjumptech/monika/commit/e8b3642851c64d88052db856ef64e03dbc5746e3?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperjumptech) (e8b3642) will **decrease** coverage by `1.75%`.
> The diff coverage is `18.68%`.

> :exclamation: Current head 08e1595 differs from pull request most recent head fcfda79. Consider uploading reports for the commit fcfda79 to get more accurate results

[![Impacted file tree graph](https://codecov.io/gh/hyperjumptech/monika/pull/618/graphs/tree.svg?width=650&height=150&src=pr&token=O3WVT7DP6F&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperjumptech)](https://codecov.io/gh/hyperjumptech/monika/pull/618?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperjumptech)

```diff
@@            Coverage Diff             @@
##             main     #618      +/-   ##
==========================================
- Coverage   55.54%   53.79%   -1.76%     
==========================================
  Files          62       63       +1     
  Lines        1856     1926      +70     
  Branches      358      377      +19     
==========================================
+ Hits         1031     1036       +5     
- Misses        688      750      +62     
- Partials      137      140       +3     
```


| [Impacted Files](https://codecov.io/gh/hyperjumptech/monika/pull/618?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperjumptech) | Coverage Δ | |
|---|---|---|
| [src/components/notification/alert-message.ts](https://codecov.io/gh/hyperjumptech/monika/pull/618/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperjumptech#diff-c3JjL2NvbXBvbmVudHMvbm90aWZpY2F0aW9uL2FsZXJ0LW1lc3NhZ2UudHM=) | `72.85% <0.00%> (-1.06%)` | :arrow_down: |
| [...c/components/notification/process-server-status.ts](https://codecov.io/gh/hyperjumptech/monika/pull/618/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperjumptech#diff-c3JjL2NvbXBvbmVudHMvbm90aWZpY2F0aW9uL3Byb2Nlc3Mtc2VydmVyLXN0YXR1cy50cw==) | `72.50% <8.33%> (-27.50%)` | :arrow_down: |
| [src/components/probe/index.ts](https://codecov.io/gh/hyperjumptech/monika/pull/618/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperjumptech#diff-c3JjL2NvbXBvbmVudHMvcHJvYmUvaW5kZXgudHM=) | `13.25% <8.33%> (-3.15%)` | :arrow_down: |
| [src/components/tcp-request/index.ts](https://codecov.io/gh/hyperjumptech/monika/pull/618/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperjumptech#diff-c3JjL2NvbXBvbmVudHMvdGNwLXJlcXVlc3QvaW5kZXgudHM=) | `10.71% <10.71%> (ø)` | |
| [src/components/logger/response-time-log.ts](https://codecov.io/gh/hyperjumptech/monika/pull/618/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperjumptech#diff-c3JjL2NvbXBvbmVudHMvbG9nZ2VyL3Jlc3BvbnNlLXRpbWUtbG9nLnRz) | `31.42% <12.50%> (ø)` | |
| [src/components/config/validate.ts](https://codecov.io/gh/hyperjumptech/monika/pull/618/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperjumptech#diff-c3JjL2NvbXBvbmVudHMvY29uZmlnL3ZhbGlkYXRlLnRz) | `66.66% <55.55%> (-3.49%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/hyperjumptech/monika/pull/618?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperjumptech).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperjumptech)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/hyperjumptech/monika/pull/618?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperjumptech). Last update [e8b3642...fcfda79](https://codecov.io/gh/hyperjumptech/monika/pull/618?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperjumptech). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperjumptech).


Patch:
diff --git a/docs/src/pages/guides/probes.md b/docs/src/pages/guides/probes.md
index 65a4d73a..95b9f36e 100644
--- a/docs/src/pages/guides/probes.md
+++ b/docs/src/pages/guides/probes.md
@@ -87,6 +87,19 @@ probes:
         ping: true
 ```
 
+### TCP
+
+You can send a TCP request to a specific `host` and `port` with `data` by using the `socket` field.
+
+```yaml
+probes:
+  - id: 'tcp-example'
+    socket:
+      host: localhost
+      port: 3333
+      data: Hello from Monika
+```
+
 ## Probe Response Anatomy
 
 The default shape of a response when Monika has successfully fetched a request is as the following.
diff --git a/src/components/config/validate.ts b/src/components/config/validate.ts
index d42fd713..11246128 100644
--- a/src/components/config/validate.ts
+++ b/src/components/config/validate.ts
@@ -26,7 +26,7 @@
 import Joi from 'joi'
 import { Notification } from '../../interfaces/notification'
 import { Config } from '../../interfaces/config'
-import { ProbeAlert } from '../../interfaces/probe'
+import { ProbeAlert, Socket } from '../../interfaces/probe'
 import { Validation } from '../../interfaces/validation'
 import { isValidURL } from '../../utils/is-valid-url'
 import { parseAlertStringTime } from '../../plugins/validate-response/checkers'
@@ -280,28 +280,32 @@ export const validateConfig = (configuration: Config): Validation => {
 
   // Check probes properties
   for (const probe of probes) {
-    const { alerts = [], requests } = probe
+    const { alerts = [], requests, socket } = probe
+    const tcpConfigError = validateTCPConfig(socket)
 
-    if ((requests?.length ?? 0) === 0) return PROBE_NO_REQUESTS
+    if (tcpConfigError) {
+      return setInvalidResponse(
+        `Monika configuration: probes.socket ${tcpConfigError}`
+      )
+    }
+
+    if ((!socket && (requests?.length ?? 0)) === 0) return PROBE_NO_REQUESTS
 
     // Check probe request properties
-    for (const request of requests) {
-      const { url } = request
+    if (requests?.length > 0) {
+      for (const request of requests) {
+        const { method, ping, url } = request
 
-      if (!url) return PROBE_REQUEST_NO_URL
+        if (!url) return PROBE_REQUEST_NO_URL
 
-      // if not a ping request and url not valid, return INVLID_URL error
-      if (request.ping !== true && !isValidURL(url)) {
-        return PROBE_REQUEST_INVALID_URL
-      }
+        // if not a ping request and url not valid, return INVLID_URL error
+        if (ping !== true && !isValidURL(url)) {
+          return PROBE_REQUEST_INVALID_URL
+        }
 
-      // if method is not set, set a default method
-      if (!request.method) {
-        request.method = 'GET'
+        if (!HTTPMethods.includes(method?.toUpperCase() ?? 'GET'))
+          return PROBE_REQUEST_INVALID_METHOD
       }
-
-      if (HTTPMethods.indexOf(request.method.toUpperCase()) < 0)
-        return PROBE_REQUEST_INVALID_METHOD
     }
 
     // Check probe alert properties
@@ -360,3 +364,18 @@ function validateSymonConfig(symonConfig?: SymonConfig) {
 
   return validationError?.error?.message
 }
+
+function validateTCPConfig(tcpConfig?: Socket) {
+  if (!tcpConfig) {
+    return ''
+  }
+
+  const schema = Joi.object({
+    host: Joi.string().required(),
+    port: Joi.number().required(),
+    data: Joi.string(),
+  })
+  const validationError = schema.validate(tcpConfig)
+
+  return validationError?.error?.message
+}
diff --git a/src/components/logger/response-time-log.ts b/src/components/logger/response-time-log.ts
index 5c269adc..bbe32ab4 100644
--- a/src/components/logger/response-time-log.ts
+++ b/src/components/logger/response-time-log.ts
@@ -23,7 +23,6 @@
  **********************************************************************************/
 
 import { differenceInHours } from 'date-fns'
-import { ProbeRequestResponse } from '../../interfaces/request'
 
 // global variable
 // we can say it is safe to use global variable here since the probe is processed sequentially as mentioned in probe/index.ts line 143: intentionally wait for a request to finish before processing next request in loop
@@ -59,19 +58,19 @@ export function checkIs24HourHasPassed() {
   return false
 }
 
-export function logResponseTime(probeRes: ProbeRequestResponse) {
+export function logResponseTime(responseTime: number): void {
   responseCount += 1
 
   if (responseCount === 1) {
     // first time
-    maxResponseTime = probeRes.responseTime
-    minResponseTime = probeRes.responseTime
-  } else if (probeRes.responseTime > maxResponseTime) {
-    maxResponseTime = probeRes.responseTime
-  } else if (probeRes.responseTime < minResponseTime) {
-    minResponseTime = probeRes.responseTime
+    maxResponseTime = responseTime
+    minResponseTime = responseTime
+  } else if (responseTime > maxResponseTime) {
+    maxResponseTime = responseTime
+  } else if (responseTime < minResponseTime) {
+    minResponseTime = responseTime
   }
 
-  totalResponseTime += probeRes.responseTime
+  totalResponseTime += responseTime
   averageResponseTime = Math.floor(totalResponseTime / responseCount)
 }
diff --git a/src/components/notification/alert-message.ts b/src/components/notification/alert-message.ts
index fedcefd3..b7bd8478 100644
--- a/src/components/notification/alert-message.ts
+++ b/src/components/notification/alert-message.ts
@@ -108,6 +108,9 @@ export async function getMessageForAlert({
           return 'Connection reset'
         case 2:
           return 'Connection refused'
+        // for TCP request
+        case 5:
+          return isRecovery ? 'The request is back to normal' : alert?.message
 
         default:
           return status
diff --git a/src/components/notification/process-server-status.ts b/src/components/notification/process-server-status.ts
index 0c5371e1..c9fbee2d 100644
--- a/src/components/notification/process-server-status.ts
+++ b/src/components/notification/process-server-status.ts
@@ -35,6 +35,19 @@ export type ServerAlertStateContext = {
   consecutiveSuccesses: number
 }
 
+type ShouldSendNotification = {
+  id: string
+  alertQuery: string
+  incidentThreshold: number
+  recoveryThreshold: number
+  isAlertTriggered: boolean
+}
+
+type ShouldSendNotificationReturn = {
+  state: 'UP' | 'DOWN'
+  shouldSendNotification: boolean
+}
+
 export const serverAlertStateInterpreters = new Map<
   string,
   Record<string, Interpreter<ServerAlertStateContext>>
@@ -163,3 +176,43 @@ export const processThresholds = ({
 
   return results
 }
+
+export function getNotificationState({
+  id,
+  alertQuery,
+  incidentThreshold,
+  recoveryThreshold,
+  isAlertTriggered,
+}: ShouldSendNotification): ShouldSendNotificationReturn {
+  if (!serverAlertStateInterpreters.has(id)) {
+    const interpreters: Record<
+      string,
+      Interpreter<ServerAlertStateContext>
+    > = {}
+    const stateMachine = serverAlertStateMachine.withContext({
+      incidentThreshold,
+      recoveryThreshold,
+      consecutiveFailures: 0,
+      consecutiveSuccesses: 0,
+    })
+
+    interpreters[alertQuery] = interpret(stateMachine).start()
+
+    serverAlertStateInterpreters.set(id, interpreters)
+  }
+
+  const interpreter = serverAlertStateInterpreters.get(id!)![alertQuery]
+  const prevStateValue = interpreter?.state?.value
+
+  interpreter?.send(isAlertTriggered ? 'FAILURE' : 'SUCCESS')
+
+  const currentStateValue = interpreter?.state?.value
+
+  return {
+    state: currentStateValue as 'UP' | 'DOWN',
+    shouldSendNotification:
+      (currentStateValue === 'DOWN' &&
+        (prevStateValue === 'UP' || !prevStateValue)) ||
+      (currentStateValue === 'UP' && prevStateValue === 'DOWN'),
+  }
+}
diff --git a/src/components/probe/index.ts b/src/components/probe/index.ts
index d00951e6..573b9ce0 100644
--- a/src/components/probe/index.ts
+++ b/src/components/probe/index.ts
@@ -34,9 +34,13 @@ import { getEventEmitter } from '../../utils/events'
 import { log } from '../../utils/pino'
 import { RequestLog } from '../logger'
 import { sendAlerts } from '../notification'
-import { processThresholds } from '../notification/process-server-status'
+import {
+  processThresholds,
+  getNotificationState,
+} from '../notification/process-server-status'
 import { probing } from './probing'
 import { logResponseTime } from '../logger/response-time-log'
+import { check } from '../tcp-request'
 
 // TODO: move this to interface file?
 interface ProbeStatusProcessed {
@@ -141,9 +145,34 @@ export async function doProbe(
   const eventEmitter = getEventEmitter()
   const responses = []
 
+  if (probe?.socket) {
+    const { id, incidentThreshold, recoveryThreshold, socket } = probe
+    const { host, port, data } = socket
+    const url = `${host}:${port}`
+    const tcpRequestID = `tcp-${id}`
+    const { duration, status } = await check({ host, port, data })
+    const timeNow = new Date().toISOString()
+    const logMessage = `${timeNow} ${checkOrder} id:${id} [TCP] ${url} ${duration}ms`
+    const isAlertTriggered = status === 'DOWN'
+
+    isAlertTriggered ? log.warn(logMessage) : log.info(logMessage)
+    logResponseTime(duration)
+
+    processTCPRequestResult({
+      probeID: id,
+      tcpRequestID,
+      tcpRequestURL: url,
+      incidentThreshold,
+      recoveryThreshold,
+      responseTime: duration,
+      isAlertTriggered,
+      notifications,
+    }).catch((error) => log.error(error.message))
+  }
+
   for (
     let requestIndex = 0;
-    requestIndex < probe.requests.length;
+    requestIndex < probe?.requests?.length;
     requestIndex++
   ) {
     const request = probe.requests[requestIndex]
@@ -154,7 +183,7 @@ export async function doProbe(
       // eslint-disable-next-line no-await-in-loop
       const probeRes: ProbeRequestResponse = await probing(request, responses)
 
-      logResponseTime(probeRes)
+      logResponseTime(probeRes.responseTime)
 
       eventEmitter.emit(events.probe.response.received, {
         probe,
@@ -230,3 +259,58 @@ export async function doProbe(
     }
   }
 }
+
+type ProcessTCPRequestResult = {
+  probeID: string
+  tcpRequestID: string
+  tcpRequestURL: string
+  incidentThreshold: number
+  recoveryThreshold: number
+  responseTime: number
+  isAlertTriggered: boolean
+  notifications: Array<Notification>
+}
+
+async function processTCPRequestResult({
+  probeID,
+  tcpRequestID,
+  tcpRequestURL,
+  incidentThreshold,
+  recoveryThreshold,
+  responseTime,
+  isAlertTriggered,
+  notifications,
+}: ProcessTCPRequestResult) {
+  const defaultAlertQuery = 'response.size < 1'
+  const defaultMessage = 'Response size is 0, expecting more than 0'
+  const { state, shouldSendNotification } = getNotificationState({
+    id: tcpRequestID,
+    alertQuery: defaultAlertQuery,
+    incidentThreshold,
+    recoveryThreshold,
+    isAlertTriggered,
+  })
+
+  if (shouldSendNotification) {
+    // TODO: Remove validation below
+    // validation is used because it is needed to send alert
+    const validation: ValidatedResponse = {
+      alert: { query: defaultAlertQuery, message: defaultMessage },
+      isAlertTriggered: true,
+      response: {
+        status: 5,
+        responseTime,
+        data: {},
+        headers: {},
+      },
+    }
+
+    await sendAlerts({
+      probeID,
+      url: tcpRequestURL,
+      probeState: state,
+      notifications: notifications ?? [],
+      validation,
+    })
+  }
+}
diff --git a/src/components/tcp-request/index.ts b/src/components/tcp-request/index.ts
new file mode 100644
index 00000000..6dae5f3b
--- /dev/null
+++ b/src/components/tcp-request/index.ts
@@ -0,0 +1,100 @@
+/**********************************************************************************
+ * MIT License                                                                    *
+ *                                                                                *
+ * Copyright (c) 2021 Hyperjump Technology                                        *
+ *                                                                                *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy   *
+ * of this software and associated documentation files (the "Software"), to deal  *
+ * in the Software without restriction, including without limitation the rights   *
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell      *
+ * copies of the Software, and to permit persons to whom the Software is          *
+ * furnished to do so, subject to the following conditions:                       *
+ *                                                                                *
+ * The above copyright notice and this permission notice shall be included in all *
+ * copies or substantial portions of the Software.                                *
+ *                                                                                *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR     *
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,       *
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE    *
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER         *
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,  *
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE  *
+ * SOFTWARE.                                                                      *
+ **********************************************************************************/
+
+import net from 'net'
+import { differenceInMilliseconds } from 'date-fns'
+
+type TCPRequest = {
+  host: string
+  port: number
+  data: string | Uint8Array
+  timeout?: number
+}
+
+type Result = {
+  duration: number
+  status: 'UP' | 'DOWN'
+  message: string
+}
+
+export async function check(tcpRequest: TCPRequest): Promise<Result> {
+  const { host, port, data, timeout } = tcpRequest
+
+  try {
+    const startTime = new Date()
+    const resp = await send({ host, port, data, timeout: timeout ?? 10 })
+    const endTime = new Date()
+    const duration = differenceInMilliseconds(endTime, startTime)
+    const responseData = Buffer.from(resp, 'utf8')
+    const isAlertTriggered = responseData?.length < 1
+
+    return { duration, status: isAlertTriggered ? 'DOWN' : 'UP', message: '' }
+  } catch (error: any) {
+    return { duration: 0, status: 'DOWN', message: error?.message }
+  }
+}
+
+async function send(
+  tcpRequest: Omit<TCPRequest, 'timeout'> & { timeout: number }
+): Promise<any> {
+  const { host, port, data, timeout } = tcpRequest
+
+  return new Promise((resolve, reject) => {
+    const client = new net.Socket()
+    client.setKeepAlive(true)
+    client.setTimeout(timeout)
+    let isConnect = false
+
+    client.connect(port, host, () => {
+      if (data) {
+        client.write(data)
+      }
+    })
+
+    client.on('data', (data) => {
+      resolve(data)
+
+      client.destroy()
+    })
+
+    client.on('error', (err) => {
+      reject(err)
+    })
+
+    client.on('connect', () => {
+      isConnect = true
+    })
+
+    client.on('timeout', () => {
+      client.destroy()
+    })
+
+    client.on('close', (hadError) => {
+      if (!isConnect || hadError) {
+        const err = new Error('Connection error')
+        reject(err)
+      }
+    })
+  })
+}
diff --git a/src/interfaces/probe.ts b/src/interfaces/probe.ts
index 7ce812a3..8d79dbec 100644
--- a/src/interfaces/probe.ts
+++ b/src/interfaces/probe.ts
@@ -30,12 +30,19 @@ export interface ProbeAlert {
   id?: string
 }
 
+export type Socket = {
+  host: string
+  port: number
+  data: string | Uint8Array
+}
+
 export interface Probe {
   id: string
   name: string
   description?: string
   interval?: number
   requests: RequestConfig[]
+  socket?: Socket
   incidentThreshold: number
   recoveryThreshold: number
   alerts: ProbeAlert[]
diff --git a/src/looper/index.ts b/src/looper/index.ts
index 0e26a963..95ab9721 100644
--- a/src/looper/index.ts
+++ b/src/looper/index.ts
@@ -41,8 +41,15 @@ const intervals: Array<NodeJS.Timeout> = []
  * @returns {object} as probe
  */
 export function sanitizeProbe(probe: Probe, id: string): Probe {
-  const { name, incidentThreshold, recoveryThreshold, alerts } = probe
+  const { name, requests, incidentThreshold, recoveryThreshold, alerts } = probe
   probe.id = `${id}`
+  probe.requests = requests?.map((request) => {
+    if (!request.method) {
+      return { ...request, method: 'GET' }
+    }
+
+    return { ...request }
+  })
 
   if (!name) {
     probe.name = `monika_${probe.id}`


Test patch:
diff --git a/src/components/notification/__tests__/process-server-status.test.ts b/src/components/notification/__tests__/process-server-status.test.ts
index 9dc6e0f7..9423f619 100644
--- a/src/components/notification/__tests__/process-server-status.test.ts
+++ b/src/components/notification/__tests__/process-server-status.test.ts
@@ -28,6 +28,7 @@ import { Probe } from '../../../interfaces/probe'
 import { ServerAlertState } from '../../../interfaces/probe-status'
 import { ValidatedResponse } from '../../../plugins/validate-response'
 import {
+  getNotificationState,
   processThresholds,
   ServerAlertStateContext,
   serverAlertStateInterpreters,
@@ -261,3 +262,103 @@ describe('processThresholds', () => {
     expect(result[0].shouldSendNotification).to.equals(false)
   })
 })
+
+describe('Send notification based on threshold', () => {
+  describe('not send notification', () => {
+    it('the incident does not cross the threshold', () => {
+      // act
+      const { state, shouldSendNotification } = getNotificationState({
+        id: 'random-id',
+        alertQuery: 'response.size < 0',
+        incidentThreshold: 3,
+        recoveryThreshold: 3,
+        isAlertTriggered: true,
+      })
+
+      // assert
+      expect(state).eq('UP')
+      expect(shouldSendNotification).eq(false)
+    })
+
+    it('the recovery does not cross the threshold', () => {
+      // act
+      getNotificationState({
+        id: 'random-id-2',
+        alertQuery: 'response.size < 1',
+        incidentThreshold: 2,
+        recoveryThreshold: 2,
+        isAlertTriggered: true,
+      })
+      getNotificationState({
+        id: 'random-id-2',
+        alertQuery: 'response.size < 1',
+        incidentThreshold: 2,
+        recoveryThreshold: 2,
+        isAlertTriggered: true,
+      })
+      const { state, shouldSendNotification } = getNotificationState({
+        id: 'random-id-2',
+        alertQuery: 'response.size < 1',
+        incidentThreshold: 2,
+        recoveryThreshold: 2,
+        isAlertTriggered: false,
+      })
+
+      // assert
+      expect(state).eq('DOWN')
+      expect(shouldSendNotification).eq(false)
+    })
+  })
+
+  describe('send notification', () => {
+    it('the incident cross the threshold', () => {
+      // act
+      const { shouldSendNotification } = getNotificationState({
+        id: 'random-id-3',
+        alertQuery: 'response.size < 2',
+        incidentThreshold: 1,
+        recoveryThreshold: 1,
+        isAlertTriggered: true,
+      })
+
+      // assert
+      expect(shouldSendNotification).eq(true)
+    })
+
+    it('the recovery cross the threshold', () => {
+      // act
+      getNotificationState({
+        id: 'random-id-4',
+        alertQuery: 'response.size < 3',
+        incidentThreshold: 2,
+        recoveryThreshold: 2,
+        isAlertTriggered: true,
+      })
+      getNotificationState({
+        id: 'random-id-4',
+        alertQuery: 'response.size < 3',
+        incidentThreshold: 2,
+        recoveryThreshold: 2,
+        isAlertTriggered: true,
+      })
+      getNotificationState({
+        id: 'random-id-4',
+        alertQuery: 'response.size < 3',
+        incidentThreshold: 2,
+        recoveryThreshold: 2,
+        isAlertTriggered: false,
+      })
+      const { state, shouldSendNotification } = getNotificationState({
+        id: 'random-id-4',
+        alertQuery: 'response.size < 3',
+        incidentThreshold: 2,
+        recoveryThreshold: 2,
+        isAlertTriggered: false,
+      })
+
+      // assert
+      expect(state).eq('UP')
+      expect(shouldSendNotification).eq(true)
+    })
+  })
+})