From 6a7636497fa010ab92969137a9a347978e13c9fa Mon Sep 17 00:00:00 2001 From: ksjaay Date: Fri, 31 Oct 2025 14:40:26 +0000 Subject: [PATCH 1/3] Adds tests for users middleware --- server/middleware/user/connections/delete.js | 1 + .../user/connections/create.test.js | 45 +++++++++++ .../user/connections/delete.test.js | 55 ++++++++++++++ .../user/connections/getAll.test.js | 45 +++++++++++ test/server/middleware/user/exists.test.js | 63 ++++++++++++++++ test/server/middleware/user/monitors.test.js | 74 +++++++++++++++++++ test/server/middleware/user/user.test.js | 36 +++++++++ 7 files changed, 319 insertions(+) create mode 100644 test/server/middleware/user/connections/create.test.js create mode 100644 test/server/middleware/user/connections/delete.test.js create mode 100644 test/server/middleware/user/connections/getAll.test.js create mode 100644 test/server/middleware/user/exists.test.js create mode 100644 test/server/middleware/user/monitors.test.js create mode 100644 test/server/middleware/user/user.test.js diff --git a/server/middleware/user/connections/delete.js b/server/middleware/user/connections/delete.js index a7a8b625..f306a101 100644 --- a/server/middleware/user/connections/delete.js +++ b/server/middleware/user/connections/delete.js @@ -1,3 +1,4 @@ +import { deleteConnection } from '../../../database/queries/connection.js'; import { handleError } from '../../../utils/errors.js'; const deleteConnectionMiddleware = async (request, response) => { diff --git a/test/server/middleware/user/connections/create.test.js b/test/server/middleware/user/connections/create.test.js new file mode 100644 index 00000000..b9d08ce6 --- /dev/null +++ b/test/server/middleware/user/connections/create.test.js @@ -0,0 +1,45 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createRequest, createResponse } from 'node-mocks-http'; +vi.mock('../../../../../server/utils/errors.js'); +vi.mock('../../../../../server/database/queries/connection.js'); + +import { handleError } from '../../../../../server/utils/errors.js'; +import { createConnection } from '../../../../../server/database/queries/connection.js'; +import createConnectionMiddleware from '../../../../../server/middleware/user/connections/create.js'; + +describe('createConnectionMiddleware', () => { + let fakeRequest, fakeResponse; + + beforeEach(() => { + fakeRequest = createRequest(); + fakeResponse = createResponse(); + fakeResponse.locals = { user: { email: 'test@example.com' } }; + createConnection = vi.fn(() => Promise.resolve()); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should create connection', async () => { + fakeRequest.body = { provider: 'github' }; + + await createConnectionMiddleware(fakeRequest, fakeResponse); + + expect(createConnection).toHaveBeenCalledWith('test@example.com', { + provider: 'github', + }); + expect(handleError).not.toHaveBeenCalled(); + }); + + it('should call handleError if error thrown', async () => { + createConnection.mockImplementationOnce(() => { + throw new Error('fail'); + }); + fakeRequest.body = { provider: 'github' }; + + await createConnectionMiddleware(fakeRequest, fakeResponse); + + expect(handleError).toHaveBeenCalledWith(expect.any(Error), fakeResponse); + }); +}); diff --git a/test/server/middleware/user/connections/delete.test.js b/test/server/middleware/user/connections/delete.test.js new file mode 100644 index 00000000..e0bf152f --- /dev/null +++ b/test/server/middleware/user/connections/delete.test.js @@ -0,0 +1,55 @@ +import { createRequest, createResponse } from 'node-mocks-http'; +import { handleError } from '../../../../../server/utils/errors.js'; +import { deleteConnection } from '../../../../../server/database/queries/connection.js'; +import deleteConnectionMiddleware from '../../../../../server/middleware/user/connections/delete.js'; + +vi.mock('../../../../../server/database/queries/connection.js'); +vi.mock('../../../../../server/utils/errors.js'); + +describe('deleteConnectionMiddleware', () => { + let fakeRequest, fakeResponse; + + beforeEach(() => { + fakeRequest = createRequest(); + fakeResponse = createResponse(); + fakeResponse.locals = { user: { email: 'test@example.com' } }; + deleteConnection = vi.fn(() => Promise.resolve()); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should return 400 if no provider', async () => { + fakeRequest.body = {}; + + await deleteConnectionMiddleware(fakeRequest, fakeResponse); + + expect(fakeResponse._getStatusCode()).toBe(400); + expect(fakeResponse._getJSONData()).toEqual({ + error: 'Provider is required', + }); + expect(handleError).not.toHaveBeenCalled(); + }); + + it('should delete connection and return 204', async () => { + fakeRequest.body = { provider: 'github' }; + + await deleteConnectionMiddleware(fakeRequest, fakeResponse); + + expect(deleteConnection).toHaveBeenCalledWith('test@example.com', 'github'); + expect(fakeResponse._getStatusCode()).toBe(204); + expect(handleError).not.toHaveBeenCalled(); + }); + + it('should call handleError if error thrown', async () => { + deleteConnection.mockImplementationOnce(() => { + throw new Error('fail'); + }); + fakeRequest.body = { provider: 'github' }; + + await deleteConnectionMiddleware(fakeRequest, fakeResponse); + + expect(handleError).toHaveBeenCalledWith(expect.any(Error), fakeResponse); + }); +}); diff --git a/test/server/middleware/user/connections/getAll.test.js b/test/server/middleware/user/connections/getAll.test.js new file mode 100644 index 00000000..8a6b1baf --- /dev/null +++ b/test/server/middleware/user/connections/getAll.test.js @@ -0,0 +1,45 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createRequest, createResponse } from 'node-mocks-http'; +vi.mock('../../../../../server/database/queries/connection.js', () => ({ + fetchConnections: vi.fn(() => Promise.resolve([{ provider: 'github' }])), +})); +vi.mock('../../../../../server/utils/errors.js', () => ({ + handleError: vi.fn(), +})); + +import getAllConnectionMiddleware from '../../../../../server/middleware/user/connections/getAll.js'; +import { handleError } from '../../../../../server/utils/errors.js'; +import { fetchConnections } from '../../../../../server/database/queries/connection.js'; + +describe('getAllConnectionMiddleware', () => { + let fakeRequest, fakeResponse; + + beforeEach(() => { + fakeRequest = createRequest(); + fakeResponse = createResponse(); + fakeResponse.locals = { user: { email: 'test@example.com' } }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should fetch connections and return 200', async () => { + await getAllConnectionMiddleware(fakeRequest, fakeResponse); + + expect(fetchConnections).toHaveBeenCalledWith('test@example.com'); + expect(fakeResponse._getStatusCode()).toBe(200); + expect(fakeResponse._getJSONData()).toEqual([{ provider: 'github' }]); + expect(handleError).not.toHaveBeenCalled(); + }); + + it('should call handleError if error thrown', async () => { + fetchConnections.mockImplementationOnce(() => { + throw new Error('fail'); + }); + + await getAllConnectionMiddleware(fakeRequest, fakeResponse); + + expect(handleError).toHaveBeenCalledWith(expect.any(Error), fakeResponse); + }); +}); diff --git a/test/server/middleware/user/exists.test.js b/test/server/middleware/user/exists.test.js new file mode 100644 index 00000000..aafd0f98 --- /dev/null +++ b/test/server/middleware/user/exists.test.js @@ -0,0 +1,63 @@ +import { createRequest, createResponse } from 'node-mocks-http'; +import { handleError } from '../../../../server/utils/errors.js'; +import { getUserByEmail } from '../../../../server/database/queries/user.js'; +import userExistsMiddleware from '../../../../server/middleware/user/exists.js'; + +vi.mock('../../../../server/database/queries/user.js'); +vi.mock('../../../../server/utils/errors.js'); + +describe('userExistsMiddleware', () => { + let fakeRequest, fakeResponse; + + beforeEach(() => { + fakeRequest = createRequest(); + fakeResponse = createResponse(); + + getUserByEmail = vi.fn((email) => + email === 'exists@example.com' ? { email } : null + ); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should return 400 if no email', async () => { + fakeRequest.body = {}; + + await userExistsMiddleware(fakeRequest, fakeResponse); + + expect(fakeResponse._getStatusCode()).toBe(400); + expect(fakeResponse._getData()).toBe('No email provided'); + expect(handleError).not.toHaveBeenCalled(); + }); + + it('should return false if user not found', async () => { + fakeRequest.body = { email: 'notfound@example.com' }; + + await userExistsMiddleware(fakeRequest, fakeResponse); + + expect(fakeResponse._getData()).toBe('false'); + expect(handleError).not.toHaveBeenCalled(); + }); + + it('should return true if user found', async () => { + fakeRequest.body = { email: 'exists@example.com' }; + + await userExistsMiddleware(fakeRequest, fakeResponse); + + expect(fakeResponse._getData()).toBe('true'); + expect(handleError).not.toHaveBeenCalled(); + }); + + it('should call handleError if error thrown', async () => { + getUserByEmail.mockImplementationOnce(() => { + throw new Error('fail'); + }); + fakeRequest.body = { email: 'exists@example.com' }; + + await userExistsMiddleware(fakeRequest, fakeResponse); + + expect(handleError).toHaveBeenCalledWith(expect.any(Error), fakeResponse); + }); +}); diff --git a/test/server/middleware/user/monitors.test.js b/test/server/middleware/user/monitors.test.js new file mode 100644 index 00000000..3dc4b5e0 --- /dev/null +++ b/test/server/middleware/user/monitors.test.js @@ -0,0 +1,74 @@ +import { createRequest, createResponse } from 'node-mocks-http'; +vi.mock('../../../../server/class/monitor/index.js'); +vi.mock('../../../../server/database/queries/certificate.js'); +vi.mock('../../../../server/database/queries/heartbeat.js'); +vi.mock('../../../../server/database/queries/monitor.js'); +vi.mock('../../../../server/utils/errors.js', () => ({ handleError: vi.fn() })); + +import userMonitorsMiddleware from '../../../../server/middleware/user/monitors.js'; +import { handleError } from '../../../../server/utils/errors.js'; +import { fetchMonitors } from '../../../../server/database/queries/monitor.js'; +import { + fetchHeartbeats, + fetchHourlyHeartbeats, +} from '../../../../server/database/queries/heartbeat.js'; +import { fetchCertificate } from '../../../../server/database/queries/certificate.js'; +import { cleanMonitor } from '../../../../server/class/monitor/index.js'; + +describe('userMonitorsMiddleware', () => { + let fakeRequest, fakeResponse; + + beforeEach(() => { + fakeRequest = createRequest(); + fakeResponse = createResponse(); + + fetchMonitors = vi.fn(() => + Promise.resolve([{ monitorId: 'm1', type: 'http' }]) + ); + + fetchHeartbeats = vi.fn(() => Promise.resolve([1, 2])); + fetchHourlyHeartbeats = vi.fn(() => Promise.resolve([1, 2])); + fetchCertificate = vi.fn(() => ({ isValid: true })); + cleanMonitor = vi.fn((m) => m); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should fetch monitors and return them', async () => { + await userMonitorsMiddleware(fakeRequest, fakeResponse); + + expect(fetchMonitors).toHaveBeenCalled(); + expect(fetchHeartbeats).toHaveBeenCalledWith('m1', 12); + expect(fetchHourlyHeartbeats).toHaveBeenCalledWith('m1', 2); + expect(fetchCertificate).toHaveBeenCalledWith('m1'); + expect(cleanMonitor).toHaveBeenCalledWith({ + monitorId: 'm1', + type: 'http', + heartbeats: [1, 2], + cert: { isValid: true }, + showFilters: true, + }); + expect(fakeResponse._getData()).toEqual([ + { + monitorId: 'm1', + type: 'http', + heartbeats: [1, 2], + cert: { isValid: true }, + showFilters: true, + }, + ]); + expect(handleError).not.toHaveBeenCalled(); + }); + + it('should call handleError if error thrown', async () => { + fetchMonitors.mockImplementationOnce(() => { + throw new Error('fail'); + }); + + await userMonitorsMiddleware(fakeRequest, fakeResponse); + + expect(handleError).toHaveBeenCalledWith(expect.any(Error), fakeResponse); + }); +}); diff --git a/test/server/middleware/user/user.test.js b/test/server/middleware/user/user.test.js new file mode 100644 index 00000000..c91dcc85 --- /dev/null +++ b/test/server/middleware/user/user.test.js @@ -0,0 +1,36 @@ +import { createRequest, createResponse } from 'node-mocks-http'; +import { handleError } from '../../../../server/utils/errors.js'; +import fetchUserMiddleware from '../../../../server/middleware/user/user.js'; + +vi.mock('../../../../server/utils/errors.js'); + +describe('fetchUserMiddleware', () => { + let fakeRequest, fakeResponse; + + beforeEach(() => { + fakeRequest = createRequest(); + fakeResponse = createResponse(); + fakeResponse.locals = { user: { email: 'test@example.com', name: 'Test' } }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should return user from res.locals', async () => { + await fetchUserMiddleware(fakeRequest, fakeResponse); + expect(fakeResponse._getData()).toEqual({ + email: 'test@example.com', + name: 'Test', + }); + expect(handleError).not.toHaveBeenCalled(); + }); + + it('should call handleError if error thrown', async () => { + fakeResponse.locals = null; + + await fetchUserMiddleware(fakeRequest, fakeResponse); + + expect(handleError).toHaveBeenCalledWith(expect.any(Error), fakeResponse); + }); +}); From 38403ebdddf12b88dbb001713f8812b0e2de4e47 Mon Sep 17 00:00:00 2001 From: ksjaay Date: Sun, 2 Nov 2025 15:15:40 +0000 Subject: [PATCH 2/3] Adds sorting to monitor heartbeats --- app/components/monitor/table.tsx | 103 +++++++++++++++++++++++ app/components/monitor/updateInfo.tsx | 66 --------------- app/components/monitor/uptime.tsx | 113 ++++++++++++++++++-------- 3 files changed, 184 insertions(+), 98 deletions(-) create mode 100644 app/components/monitor/table.tsx delete mode 100644 app/components/monitor/updateInfo.tsx diff --git a/app/components/monitor/table.tsx b/app/components/monitor/table.tsx new file mode 100644 index 00000000..e2fff217 --- /dev/null +++ b/app/components/monitor/table.tsx @@ -0,0 +1,103 @@ +import { useState, type ReactNode } from 'react'; + +type Column = { + key: keyof T; + label: string; + sortable?: boolean; + render?: (value: any, row: T) => ReactNode; + defaultValue?: string; +}; + +type TableProps = { + columns: Column[]; + data: T[]; + initialSortKey?: keyof T; + initialSortDirection?: 'asc' | 'desc'; + className?: string; +}; + +function sortData(data: T[], key: keyof T, direction: 'asc' | 'desc') { + return [...data].sort((a, b) => { + const aValue = a[key]; + const bValue = b[key]; + if (aValue == null && bValue == null) return 0; + if (aValue == null) return 1; + if (bValue == null) return -1; + if (aValue < bValue) return direction === 'asc' ? -1 : 1; + if (aValue > bValue) return direction === 'asc' ? 1 : -1; + + return 0; + }); +} + +export function Table>({ + columns, + data, + initialSortKey, + initialSortDirection = 'asc', + className = '', +}: TableProps) { + const [sortKey, setSortKey] = useState(initialSortKey); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>( + initialSortDirection + ); + + const handleSort = (key: keyof T) => { + if (sortKey === key) { + setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc')); + } else { + setSortKey(key); + setSortDirection('asc'); + } + }; + + const sortedData = sortKey ? sortData(data, sortKey, sortDirection) : data; + + return ( +
+
+ {columns.map((col) => ( +
+ {col.sortable ? ( + + ) : ( + col.label + )} +
+ ))} +
+ {sortedData.map((row, rowIdx) => ( +
+ {columns.map((col) => + col.render ? ( + col.render(row[col.key], row) + ) : ( +
+ {row[col.key] || col.defaultValue} +
+ ) + )} +
+ ))} +
+ ); +} diff --git a/app/components/monitor/updateInfo.tsx b/app/components/monitor/updateInfo.tsx deleted file mode 100644 index f9418c70..00000000 --- a/app/components/monitor/updateInfo.tsx +++ /dev/null @@ -1,66 +0,0 @@ -// import dependencies -import dayjs from 'dayjs'; -import classNames from 'classnames'; -import { Tooltip } from '@lunalytics/ui'; -import { useTranslation } from 'react-i18next'; - -// import local files -import useLocalStorageContext from '../../hooks/useLocalstorage'; -import type { HeartbeatProps } from '../../types/monitor'; - -interface UptimeInfoProps { - heartbeat: HeartbeatProps; - highestLatency: number; -} - -const UptimeInfo = ({ heartbeat, highestLatency = 0 }: UptimeInfoProps) => { - const { dateformat, timeformat, timezone } = useLocalStorageContext(); - const { t } = useTranslation(); - - const classes = classNames('monitor-uptime-info-button', { - 'monitor-uptime-info-button-inactive': heartbeat?.isDown, - 'monitor-uptime-info-button-active': !heartbeat?.isDown, - }); - - return ( -
-
- {heartbeat?.isDown - ? t('home.monitor.table.down') - : t('home.monitor.table.up')} -
-
- {dayjs( - new Date(heartbeat.date).toLocaleString('en-US', { - timeZone: timezone, - }) - ).format(`${dateformat} ${timeformat}`)} -
-
- {heartbeat.message || 'Unknown'} -
- - -
-
-
-
-
- -
- ); -}; - -UptimeInfo.displayName = 'UptimeInfo'; - -export default UptimeInfo; diff --git a/app/components/monitor/uptime.tsx b/app/components/monitor/uptime.tsx index 96152d3b..dce3e200 100644 --- a/app/components/monitor/uptime.tsx +++ b/app/components/monitor/uptime.tsx @@ -1,12 +1,15 @@ import './uptime.scss'; // import dependencies +import dayjs from 'dayjs'; import { useMemo } from 'react'; +import classNames from 'classnames'; +import { Tooltip } from '@lunalytics/ui'; +import { observer } from 'mobx-react-lite'; import { useTranslation } from 'react-i18next'; // import local files -import UptimeInfo from './updateInfo'; -import { observer } from 'mobx-react-lite'; +import { Table } from './table'; import useContextStore from '../../context'; const MonitorUptime = () => { @@ -16,41 +19,87 @@ const MonitorUptime = () => { globalStore: { activeMonitor }, } = useContextStore(); - const heartbeatList = useMemo(() => { - const highestLatency = + const highestLatency = useMemo(() => { + return ( activeMonitor?.heartbeats?.reduce((acc, curr) => { return Math.max(acc, curr.latency); - }, 0) || 0; - - const heartbeatList = activeMonitor?.heartbeats?.map((heartbeat) => ( - - )); - - return heartbeatList; + }, 0) || 0 + ); }, [JSON.stringify(activeMonitor)]); return ( -
-
-
- {t('home.monitor.table.status')} -
-
- {t('home.monitor.table.time')} -
-
- {t('home.monitor.table.message')} -
-
- {t('home.monitor.headers.latency')} -
-
- {heartbeatList} -
+ <> + {activeMonitor?.heartbeats && ( + { + const classes = classNames('monitor-uptime-info-button', { + 'monitor-uptime-info-button-inactive': value, + 'monitor-uptime-info-button-active': !value, + }); + + return ( +
+ {value + ? t('home.monitor.table.down') + : t('home.monitor.table.up')} +
+ ); + }, + }, + { + key: 'date', + label: t('home.monitor.table.time'), + sortable: true, + render: (value) => ( +
+ {dayjs(new Date(value).toLocaleString('en-US', {})).format( + `DD/MM/YYYY HH:mm:ss` + )} +
+ ), + }, + { + key: 'message', + label: t('home.monitor.table.message'), + sortable: true, + defaultValue: 'Unknown', + }, + { + key: 'latency', + label: t('home.monitor.headers.latency'), + sortable: true, + render: (value) => ( + +
+
+
+
+
+ + ), + }, + ]} + data={activeMonitor?.heartbeats} + initialSortKey="date" + initialSortDirection="desc" + /> + )} + ); }; From a3e91ae096fbd4670ec1e88e33b6b2ea12767736 Mon Sep 17 00:00:00 2001 From: ksjaay Date: Sun, 2 Nov 2025 15:16:11 +0000 Subject: [PATCH 3/3] Bumps package.json version --- package-lock.json | 14 +++++++------- package.json | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 78c3c77d..e16f4cee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lunalytics", - "version": "0.10.7", + "version": "0.10.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lunalytics", - "version": "0.10.7", + "version": "0.10.8", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@dnd-kit/core": "6.3.1", @@ -19,7 +19,7 @@ "better-sqlite3": "11.10.0", "classnames": "2.5.1", "compare-versions": "6.1.1", - "compression": "^1.8.1", + "compression": "1.8.1", "cookie-parser": "1.4.7", "cors": "2.8.5", "cron": "4.3.1", @@ -62,8 +62,8 @@ "@types/react-dom": "19.1.7", "@types/react-window": "1.8.8", "@vitejs/plugin-react-swc": "4.0.0", - "@vitest/coverage-v8": "^4.0.4", - "@vitest/ui": "^4.0.4", + "@vitest/coverage-v8": "4.0.4", + "@vitest/ui": "4.0.4", "concurrently": "9.2.0", "cypress": "14.4.1", "eslint": "9.33.0", @@ -80,9 +80,9 @@ "sass": "1.89.2", "typescript": "5.9.2", "typescript-eslint": "8.39.1", - "vite": "^7.1.12", + "vite": "7.1.12", "vite-plugin-compression2": "2.2.0", - "vitest": "^4.0.4" + "vitest": "4.0.4" }, "engines": { "node": ">= 22.x" diff --git a/package.json b/package.json index b9892841..be6680bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lunalytics", - "version": "0.10.7", + "version": "0.10.8", "description": "Open source Node.js server/website monitoring tool", "private": true, "author": "KSJaay ",