diff --git a/signup-service/src/main/java/io/mosip/signup/controllers/WebSocketController.java b/signup-service/src/main/java/io/mosip/signup/controllers/WebSocketController.java index 4d9d1668d..b93ab3098 100644 --- a/signup-service/src/main/java/io/mosip/signup/controllers/WebSocketController.java +++ b/signup-service/src/main/java/io/mosip/signup/controllers/WebSocketController.java @@ -23,6 +23,7 @@ import javax.validation.Valid; import java.util.Objects; +import java.util.Optional; import static io.mosip.signup.api.util.ErrorConstants.PLUGIN_NOT_FOUND; import static io.mosip.signup.util.SignUpConstants.SOCKET_USERNAME_SEPARATOR; diff --git a/signup-ui/package.json b/signup-ui/package.json index deda76af2..a09e943dd 100644 --- a/signup-ui/package.json +++ b/signup-ui/package.json @@ -58,9 +58,11 @@ "react-webcam": "^7.2.0", "rooks": "^7.14.1", "socket.io-client": "^4.7.5", + "stomp-broker-js": "^1.3.0", "tailwind-merge": "^2.0.0", "tailwindcss-animate": "^1.0.7", "typescript": "^4.9.5", + "uuid": "^10.0.0", "web-vitals": "^2.1.4", "yup": "^1.3.2", "zustand": "^4.4.6" @@ -105,6 +107,8 @@ "@types/lodash": "^4.14.200", "@types/platform": "^1.3.6", "@types/react-google-recaptcha": "^2.1.7", + "@types/text-encoding": "^0.0.39", + "@types/uuid": "^10.0.0", "autoprefixer": "^10.4.16", "craco-alias": "^3.0.1", "eslint": "^8.52.0", @@ -121,6 +125,7 @@ "storybook": "^7.6.0", "tailwindcss": "^3.4.1", "tailwindcss-dir": "^4.0.0", + "text-encoding": "^0.7.0", "ts-jest": "^29.1.4", "tsconfig-paths-webpack-plugin": "^4.1.0", "type-fest": "^4.6.0", diff --git a/signup-ui/src/global.d.ts b/signup-ui/src/global.d.ts index 335d5d121..31e077abe 100644 --- a/signup-ui/src/global.d.ts +++ b/signup-ui/src/global.d.ts @@ -1 +1,37 @@ /// + +declare module "mock-stomp-broker" { + interface Config { + port?: number; + portRange?: [number, number]; + endpoint?: string; + } + + class MockStompBroker { + private static PORTS_IN_USE; + private static BASE_SESSION; + private static getRandomInt; + private static getPort; + private readonly port; + private readonly httpServer; + private readonly stompServer; + private readonly sentMessageIds; + private queriedSessionIds; + private sessions; + private thereAreNewSessions; + private setMiddleware; + private registerMiddlewares; + + constructor({ port, portRange, endpoint }?: Config); + + public newSessionsConnected(): Promise; + public subscribed(sessionId: string): Promise; + public scheduleMessage(topic: string, payload: any, headers?: {}): string; + public messageSent(messageId: string): Promise; + public disconnected(sessionId: string): Promise; + public kill(): void; + public getPort(): number; + } + + export default MockStompBroker; +} diff --git a/signup-ui/src/index.tsx b/signup-ui/src/index.tsx index 37f81d8d1..6764e4469 100644 --- a/signup-ui/src/index.tsx +++ b/signup-ui/src/index.tsx @@ -6,9 +6,14 @@ import App from "./App"; import "./services/i18n.service"; import "react-tooltip/dist/react-tooltip.css"; +if (process.env.NODE_ENV === "development") { + import("./mocks/msw-browser").then(({ mswWorker }) => mswWorker.start()); +} + const root = ReactDOM.createRoot( document.getElementById("root") as HTMLElement ); + root.render( diff --git a/signup-ui/src/mocks/handlers/index.ts b/signup-ui/src/mocks/handlers/index.ts index 5d80e72c2..797100e2e 100644 --- a/signup-ui/src/mocks/handlers/index.ts +++ b/signup-ui/src/mocks/handlers/index.ts @@ -1,3 +1,9 @@ import { checkSlotHandlers } from "./slot-checking"; +import { testConnectionHandlers } from "./test-connection"; -export const handlers = [...checkSlotHandlers]; +export const handlers = [ + // intercept the "test connection" endpoint + ...testConnectionHandlers, + // intercept the "check slot" endpoint + ...checkSlotHandlers, +]; diff --git a/signup-ui/src/mocks/handlers/test-connection.ts b/signup-ui/src/mocks/handlers/test-connection.ts new file mode 100644 index 000000000..f97c54096 --- /dev/null +++ b/signup-ui/src/mocks/handlers/test-connection.ts @@ -0,0 +1,16 @@ +import { http, HttpResponse } from "msw"; + +// endpoint to be intercepted +const testConnectionEP = "http://localhost:8088/v1/signup/test"; + +const testConnectionSuccess = http.get(testConnectionEP, async () => { + return HttpResponse.json({ + responseTime: "2024-03-25T18:10:18.520Z", + response: { + connection: true, + }, + errors: [], + }); +}); + +export const testConnectionHandlers = [testConnectionSuccess]; diff --git a/signup-ui/src/mocks/msw-browser.ts b/signup-ui/src/mocks/msw-browser.ts new file mode 100644 index 000000000..307ece064 --- /dev/null +++ b/signup-ui/src/mocks/msw-browser.ts @@ -0,0 +1,5 @@ +import { setupWorker } from "msw/browser"; + +import { handlers } from "./handlers"; + +export const mswWorker = setupWorker(...handlers); diff --git a/signup-ui/src/mocks/msw-server.ts b/signup-ui/src/mocks/msw-server.ts deleted file mode 100644 index 2e664c6f7..000000000 --- a/signup-ui/src/mocks/msw-server.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { setupServer } from "msw/node"; - -import { handlers } from "./handlers"; - -export const mswServer = setupServer(...handlers); diff --git a/signup-ui/src/pages/EkycVerificationPage/VerificationScreen/__tests__/VerificationScreen.test.tsx b/signup-ui/src/pages/EkycVerificationPage/VerificationScreen/__tests__/VerificationScreen.test.tsx new file mode 100644 index 000000000..1e4984367 --- /dev/null +++ b/signup-ui/src/pages/EkycVerificationPage/VerificationScreen/__tests__/VerificationScreen.test.tsx @@ -0,0 +1,134 @@ +import { QueryCache, QueryClient } from "@tanstack/react-query"; +import { screen } from "@testing-library/react"; + +import { renderWithClient } from "~utils/test"; + +import { VerificationScreen } from "../VerificationScreen"; +import { SettingsDto } from "~typings/types"; + +describe("Web socket connection between the front end and back end", () => { + const queryCache = new QueryCache(); + const queryClient = new QueryClient({ queryCache }); + + const settings = { + response: { + configs: { + "slot.request.limit": 2, + "slot.request.delay": 1, + }, + }, + } as SettingsDto; + const cancelPopup = jest.fn(); + + it("should be on", () => { + // TODO: will add the test implementation once some web socket structure is given + + // Arrange + + // Act + renderWithClient(queryClient, ); + + // Assert + // the connection should be on + }); +}); + +describe("VerificationScreen (vs)", () => { + const queryCache = new QueryCache(); + const queryClient = new QueryClient({ queryCache }); + + it("should render correctly", () => { + // Arrange + renderWithClient(queryClient, ); + + // Act + + // Assert + const vs = screen.getByTestId("vs"); + expect(vs).toBeInTheDocument(); + }); + + it("should show onscreen instructions above the video frame sent from eKYC provider", () => { + // Arrange + // TODO: mock instruction of an eKYC provider + + renderWithClient(queryClient, ); + + // Act + + // Assert + const vsOnScreenInstruction = screen.getByTestId("vs-onscreen-instruction"); + expect(vsOnScreenInstruction).toBeInTheDocument(); + }); + + it("should show liveliness verification screen", () => { + // Arrange + renderWithClient(queryClient, ); + + // Act + + // Assert + const vsLiveliness = screen.getByTestId("vs-liveliness"); + expect(vsLiveliness).toBeInTheDocument(); + }); + + it("should show solid colors across the full screen for color based frame verification", async () => { + // Arrange + renderWithClient(queryClient, ); + + // Act + // TODO: add wait for x seconds + + // Assert + const vsSolidColorScreen = screen.getByTestId("vs-solid-color-screen"); + expect(vsSolidColorScreen).toBeInTheDocument(); + }); + + it("should show NID verification screen", () => { + // Arrange + renderWithClient(queryClient, ); + + // Act + + // Assert + const vsNID = screen.getByTestId("vs-nid"); + expect(vsNID).toBeInTheDocument(); + }); + + it("should show feedback message when verification fails", () => { + // Arrange + // TODO: mock failed verification + renderWithClient(queryClient, ); + + // Act + + // Assert + const vsFailedVerification = screen.getByTestId("vs-failed-verification"); + expect(vsFailedVerification).toBeInTheDocument(); + }); + + it("should show warning message if there is any technical issue", () => { + // Arrange + // TODO: mock technical issue: internet connection lost, ... + renderWithClient(queryClient, ); + + // Act + + // Assert + const vsTechnicalIssueWarningMsg = screen.getByTestId( + "vs-technical-issue-warning-msg" + ); + expect(vsTechnicalIssueWarningMsg).toBeInTheDocument(); + }); + + it("should be redirected to the leading screen when the verification is successful", () => { + // Arrange + // TODO: mock successful verification + renderWithClient(queryClient, ); + + // Act + + // Assert + // to be redirected to and land on the leading screen + }); +}); diff --git a/signup-ui/src/setupTests.ts b/signup-ui/src/setupTests.ts index 02edb9eee..05a539b7e 100644 --- a/signup-ui/src/setupTests.ts +++ b/signup-ui/src/setupTests.ts @@ -4,7 +4,7 @@ // learn more: https://github.com/testing-library/jest-dom import "@testing-library/jest-dom"; -import { mswServer } from "./mocks/msw-server"; +import { mswWorker } from "./mocks/msw-browser"; require("whatwg-fetch"); @@ -26,7 +26,3 @@ jest.mock("react-i18next", () => ({ }; }, })); - -beforeAll(() => mswServer.listen()); -afterEach(() => mswServer.resetHandlers()); -afterAll(() => mswServer.close()); diff --git a/signup-ui/src/typings/stompBroker.d.ts b/signup-ui/src/typings/stompBroker.d.ts new file mode 100644 index 000000000..42b9131da --- /dev/null +++ b/signup-ui/src/typings/stompBroker.d.ts @@ -0,0 +1 @@ +declare module 'stomp-broker-js'; diff --git a/signup-ui/src/utils/stompBroker/getStompClient.ts b/signup-ui/src/utils/stompBroker/getStompClient.ts new file mode 100644 index 000000000..1daa2744a --- /dev/null +++ b/signup-ui/src/utils/stompBroker/getStompClient.ts @@ -0,0 +1,31 @@ +import { Message, Client } from "@stomp/stompjs"; + +interface ClientArgs { + port: number; + topic?: string; + onMessage?: (message: Message) => void; + endpoint?: string; +} + +const getStompClient = ({ + port, + topic, + onMessage = jest.fn(), + endpoint = "/websocket" +}: ClientArgs): Client => { + const client = new Client({ + brokerURL: `ws://localhost:${port}${endpoint}` + }); + + if (topic) { + client.onConnect = () => { + client.subscribe(topic, onMessage); + }; + } + + client.activate(); + + return client; +}; + +export default getStompClient; diff --git a/signup-ui/src/utils/stompBroker/mockStompBroker.ts b/signup-ui/src/utils/stompBroker/mockStompBroker.ts new file mode 100644 index 000000000..613f29e22 --- /dev/null +++ b/signup-ui/src/utils/stompBroker/mockStompBroker.ts @@ -0,0 +1,206 @@ +import {v4 as uuid} from "uuid"; +import http, { Server } from "http"; +import StompServer from "stomp-broker-js"; +import waitUntil from "./waitUntil"; + +// interface Global extends NodeJS.Global { +// TextEncoder: TextEncoder; +// TextDecoder: TextDecoder; +// } + +// declare var global: Global; + +// global.TextEncoder = global.TextEncoder || TextEncoder; +// global.TextDecoder = global.TextDecoder || TextDecoder; + +type CallNextMiddleWare = () => boolean; +type MiddlewareStrategy = [ + string, + (args: { sessionId: string; frame: Frame }) => void +]; + +interface Socket { + sessionId: string; +} + +interface MiddlewareArgs { + frame: Frame; +} + +interface Frame { + headers: { + mockMessageId: string; + }; +} + +interface Session { + sessionId: string; + hasConnected: boolean; + hasReceivedSubscription: boolean; + hasSentMessage: boolean; + hasDisconnected: boolean; +} + +interface Sessions { + [sessionId: string]: Session; +} + +interface Config { + port?: number; + portRange?: [number, number]; + endpoint?: string; +} + +class MockStompBroker { + private static PORTS_IN_USE: number[] = []; + private static BASE_SESSION = { + hasConnected: false, + hasReceivedSubscription: false, + hasSentMessage: false, + hasDisconnected: false + }; + + private static getRandomInt(min: number, max: number): number { + return Math.floor(Math.random() * (max - min)) + min; + } + + private static getPort(portRange: [number, number] = [8000, 9001]): number { + const minInclusive = portRange[0]; + const maxExclusive = portRange[1]; + const port = this.getRandomInt(minInclusive, maxExclusive); + + return this.PORTS_IN_USE.includes(port) ? this.getPort() : port; + } + + private readonly port: number; + private readonly httpServer: Server; + private readonly stompServer: any; + private readonly sentMessageIds: string[] = []; + private queriedSessionIds: string[] = []; + private sessions: Sessions = {}; + + constructor({ port, portRange, endpoint = "/websocket" }: Config = {}) { + this.thereAreNewSessions = this.thereAreNewSessions.bind(this); + this.registerMiddlewares = this.registerMiddlewares.bind(this); + this.setMiddleware = this.setMiddleware.bind(this); + + this.port = port || MockStompBroker.getPort(portRange); + this.httpServer = http.createServer(); + + this.stompServer = new StompServer({ + server: this.httpServer, + path: endpoint + }); + + this.registerMiddlewares(); + this.httpServer.listen(this.port); + } + + public async newSessionsConnected(): Promise { + await waitUntil(this.thereAreNewSessions, "No new sessions established"); + + const newSessionsIds = Object.values(this.sessions) + .filter(({ sessionId }) => !this.queriedSessionIds.includes(sessionId)) + .filter(({ hasConnected }) => hasConnected) + .map(({ sessionId }) => sessionId); + + this.queriedSessionIds = this.queriedSessionIds.concat(newSessionsIds); + + return newSessionsIds; + } + + public subscribed(sessionId: string) { + return waitUntil(() => { + const session = this.sessions[sessionId]; + return Boolean(session && session.hasReceivedSubscription); + }, `Session ${sessionId} never subscribed to a topic`); + } + + public scheduleMessage( + topic: string, + payload: any, + headers: {} = { + "content-type": "application/json;charset=UTF-8" + } + ): string { + const body = JSON.stringify(payload); + const mockMessageId = uuid(); + this.stompServer.send(`/${topic}`, { ...headers, mockMessageId }, body); + + return mockMessageId; + } + + public messageSent(messageId: string) { + return waitUntil( + () => this.sentMessageIds.includes(messageId), + `Message ${messageId} was never sent` + ); + } + + public disconnected(sessionId: string) { + return waitUntil(() => { + const session = this.sessions[sessionId]; + + return Boolean(session && session.hasDisconnected); + }, `Session ${sessionId} never disconnected`); + } + + public kill() { + this.httpServer.close(); + } + + public getPort() { + return this.port; + } + + private thereAreNewSessions(): boolean { + const numberOfSessions = Object.entries(this.sessions).length; + const numberOfSessionsQueried = this.queriedSessionIds.length; + + return numberOfSessions - numberOfSessionsQueried > 0; + } + + private setMiddleware([event, middlewareHook]: MiddlewareStrategy) { + this.stompServer.setMiddleware( + event, + (socket: Socket, args: MiddlewareArgs, next: CallNextMiddleWare) => { + process.nextTick(() => + middlewareHook({ sessionId: socket.sessionId, frame: args.frame }) + ); + + return next(); + } + ); + } + + private registerMiddlewares() { + const strategies: MiddlewareStrategy[] = [ + [ + "connect", + ({ sessionId }) => + (this.sessions[sessionId] = { + ...MockStompBroker.BASE_SESSION, + sessionId, + hasConnected: true + }) + ], + [ + "subscribe", + ({ sessionId }) => + (this.sessions[sessionId].hasReceivedSubscription = true) + ], + [ + "send", + ({ frame }) => this.sentMessageIds.push(frame.headers.mockMessageId) + ], + [ + "disconnect", + ({ sessionId }) => (this.sessions[sessionId].hasDisconnected = true) + ] + ]; + + strategies.forEach(this.setMiddleware); + } +} + +export default MockStompBroker; diff --git a/signup-ui/src/utils/stompBroker/waitUntil.ts b/signup-ui/src/utils/stompBroker/waitUntil.ts new file mode 100644 index 000000000..17209845d --- /dev/null +++ b/signup-ui/src/utils/stompBroker/waitUntil.ts @@ -0,0 +1,29 @@ +const MAX_MILLIS = 2000; + +const waitUntil = ( + predicate: () => boolean, + errorMessage: string = `Predicate did not become true in ${MAX_MILLIS}ms` +): Promise => { + let timedOut = false; + const timeout = setTimeout(() => (timedOut = true), MAX_MILLIS); + const recursivelyResolve = ( + resolve: () => void, + reject: (message: string) => void + ) => { + if (timedOut) { + reject(errorMessage); + } + if (predicate()) { + clearTimeout(timeout); + resolve(); + } else { + setTimeout(() => recursivelyResolve(resolve, reject), 10); + } + }; + + return new Promise((resolve, reject) => { + recursivelyResolve(resolve, reject); + }); +}; + +export default waitUntil; diff --git a/signup-ui/tsconfig.json b/signup-ui/tsconfig.json index 372f4e329..433b7575c 100644 --- a/signup-ui/tsconfig.json +++ b/signup-ui/tsconfig.json @@ -22,7 +22,8 @@ "baseUrl": "." }, "include": [ - "./src" + "./src", + "./src/**/*.d.ts" ], "extends": "./tsconfig.paths.json" }