diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..550315e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,83 @@ +name: CI + +on: + push: + branches: [main, master, feature/*] + pull_request: + branches: [main, master] + +jobs: + server-tests: + name: Server tests + runs-on: ubuntu-latest + defaults: + run: + working-directory: server/market-trading-service + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Use Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Cache server node modules + uses: actions/cache@v4 + with: + path: server/market-trading-service/node_modules + key: ${{ runner.os }}-server-${{ hashFiles('server/market-trading-service/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-server- + + - name: Install server dependencies + run: npm ci + + - name: Run server tests + run: npm test --silent + + client-tests: + name: Client tests (conditional) + runs-on: ubuntu-latest + needs: server-tests + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Detect client test script + id: detect_client_tests + run: | + set -e + PKG=client/trading-dashboard/package.json + if [ -f "$PKG" ] && grep -q '"test"' "$PKG"; then + echo "has_tests=true" >> $GITHUB_OUTPUT + else + echo "has_tests=false" >> $GITHUB_OUTPUT + fi + + - name: Use Node.js 20 + if: steps.detect_client_tests.outputs.has_tests == 'true' + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Cache client node modules + if: steps.detect_client_tests.outputs.has_tests == 'true' + uses: actions/cache@v4 + with: + path: client/trading-dashboard/node_modules + key: ${{ runner.os }}-client-${{ hashFiles('client/trading-dashboard/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-client- + + - name: Install client dependencies + if: steps.detect_client_tests.outputs.has_tests == 'true' + run: | + cd client/trading-dashboard + npm ci + + - name: Run client tests + if: steps.detect_client_tests.outputs.has_tests == 'true' + run: | + cd client/trading-dashboard + npm test --silent diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..104fe0b --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,44 @@ +name: E2E (Cypress) + +on: + push: + branches: [main, master, feature/*] + pull_request: + branches: [main, master] + +jobs: + cypress-run: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node 20 + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Start client & run Cypress (no backend) + uses: cypress-io/github-action@v6 + with: + working-directory: client/trading-dashboard + build: npm run build + start: npm start + wait-on: "http://localhost:3000" + wait-on-timeout: 120 + browser: chrome + spec: cypress/e2e/**/*.cy.ts + env: + # Point to an unreachable host so fetch fails and client falls back to MOCK_TICKERS + NEXT_PUBLIC_MARKET_TRADING_URL: http://127.0.0.1:59999 + CYPRESS_BASE_URL: http://localhost:3000 + + - name: Upload Cypress videos/screenshots (if failed) + if: failure() + uses: actions/upload-artifact@v4 + with: + name: cypress-artifacts + path: | + client/trading-dashboard/cypress/videos/** + client/trading-dashboard/cypress/screenshots/** diff --git a/README.md b/README.md index 51ccee2..26770b7 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,56 @@ # multi-bank-coding-challenge -Full Stack real-time trading dashboard + +Full-stack real-time trading dashboard used for the Multi Bank coding challenge. This repository contains two main projects and supporting documentation. + +Project layout + +- `client/trading-dashboard` — Next.js frontend (UI, E2E + unit tests). See: `client/trading-dashboard/README.md` and `client/trading-dashboard/README.md` for developer instructions. +- `server/market-trading-service` — Node + TypeScript microservice that simulates market data and exposes a small REST API. See: `server/market-trading-service/README.md`. +- `docs/` — architecture and project-scope documentation. +- `docker-compose.yml` & `README.DOCKER.md` — Docker setup for running both services together. + +Quick start (recommended: Docker) + +1. From the repository root, build and start both services with docker-compose: + +```bash +docker-compose build +docker-compose up +``` + +This will start: + +- Frontend: http://localhost:3000 +- Backend API: http://localhost:3005 (health endpoint: `/health`) + +Local development (without Docker) + +- Frontend: + +```bash +cd client/trading-dashboard +npm install +npm run dev +``` + +- Backend: + +```bash +cd server/market-trading-service +npm install +npm run dev +``` + +Tests + +- Frontend unit tests: run in `client/trading-dashboard` with `npm run test` (Jest) +- Frontend E2E: run Cypress after starting the frontend (see `client/trading-dashboard/cypress.config.ts`) + +Notes + +- Each subproject includes its own README with more detailed developer and testing instructions. If you plan to contribute, please follow the subproject README before opening a PR. +- If you want CI/CD integration, I can add GitHub Actions workflows that run unit tests and optional Cypress E2E tests. + +License + +This project is available under the repository LICENSE file. diff --git a/client/trading-dashboard/app/__tests__/page.test.tsx b/client/trading-dashboard/app/__tests__/page.test.tsx new file mode 100644 index 0000000..0d0ecae --- /dev/null +++ b/client/trading-dashboard/app/__tests__/page.test.tsx @@ -0,0 +1,32 @@ +import { render, screen } from "@testing-library/react"; +import Index from "../page"; +// IMPORTANT: mock child components BEFORE importing the page so the page uses the mocked versions. +jest.mock("@/components/Hero", () => ({ + __esModule: true, + default: () =>
+ Sorry, the page you are looking for doesn't exist or has been + moved. +
+ +- {new Date(label).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - })} -
-- {formatCurrency(payload[0].value)} -
-+ {new Date(label).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} +
++ {formatCurrency(payload[0].value)} +
+
diff --git a/client/trading-dashboard/cypress.config.ts b/client/trading-dashboard/cypress.config.ts
new file mode 100644
index 0000000..84d334c
--- /dev/null
+++ b/client/trading-dashboard/cypress.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from "cypress";
+
+export default defineConfig({
+ e2e: {
+ baseUrl: "http://localhost:3000",
+ specPattern: "cypress/e2e/**/*.cy.{js,ts}",
+ supportFile: "cypress/support/e2e.ts",
+ },
+});
diff --git a/client/trading-dashboard/cypress/e2e/home.cy.ts b/client/trading-dashboard/cypress/e2e/home.cy.ts
new file mode 100644
index 0000000..bc6e6fc
--- /dev/null
+++ b/client/trading-dashboard/cypress/e2e/home.cy.ts
@@ -0,0 +1,61 @@
+describe("Homepage", () => {
+ context("base rendering", () => {
+ beforeEach(() => {
+ cy.intercept("GET", "**/api/tickers", { fixture: "tickers.json" }).as(
+ "getTickers"
+ );
+ cy.visit("/");
+ cy.wait("@getTickers");
+ });
+
+ it("shows the hero heading", () => {
+ cy.contains("Live Market Data").should("be.visible");
+ });
+
+ it("renders the ticker grid with items from API", () => {
+ cy.get('[data-testid="ticker-grid"]').within(() => {
+ cy.get('[data-testid^="ticker-card-"]').should("have.length", 5);
+ });
+ // Spot-check specific items using dynamic data-testid
+ cy.get('[data-testid="ticker-card-AAPL"]').should("be.visible");
+ cy.get('[data-testid="ticker-card-TSLA"]').should("be.visible");
+ });
+
+ it("formats prices and percent changes correctly", () => {
+ // Positive change should be green
+ cy.get('[data-testid="ticker-card-AAPL"]').within(() => {
+ cy.contains("$185.50").should("be.visible");
+ cy.contains("+0.82%").should("be.visible");
+ cy.get('[data-testid="ticker-change-AAPL"]').should(
+ "have.class",
+ "text-success"
+ );
+ });
+
+ // Negative change should be red
+ cy.get('[data-testid="ticker-card-TSLA"]').within(() => {
+ cy.contains("-1.70%").should("be.visible");
+ cy.get('[data-testid="ticker-change-TSLA"]').should(
+ "have.class",
+ "text-destructive"
+ );
+ });
+ });
+
+ it("shows the news section header on the homepage", () => {
+ cy.contains(/Market News/i).should("be.visible");
+ });
+
+ it("is responsive on mobile and desktop", () => {
+ // Desktop
+ cy.viewport(1280, 800);
+ cy.contains("Live Market Data").should("be.visible");
+ cy.get('[data-testid="ticker-grid"]').should("be.visible");
+
+ // Mobile
+ cy.viewport(375, 667);
+ cy.contains("Live Market Data").should("be.visible");
+ cy.get('[data-testid="ticker-grid"]').should("be.visible");
+ });
+ });
+});
diff --git a/client/trading-dashboard/cypress/fixtures/ticker-history.json b/client/trading-dashboard/cypress/fixtures/ticker-history.json
new file mode 100644
index 0000000..be9889f
--- /dev/null
+++ b/client/trading-dashboard/cypress/fixtures/ticker-history.json
@@ -0,0 +1,44 @@
+{
+ "data": [
+ {
+ "timestamp": "2025-11-07T00:00:00.000Z",
+ "price": 180.5,
+ "volume": 52000000
+ },
+ {
+ "timestamp": "2025-11-08T00:00:00.000Z",
+ "price": 181.25,
+ "volume": 48000000
+ },
+ {
+ "timestamp": "2025-11-09T00:00:00.000Z",
+ "price": 179.8,
+ "volume": 55000000
+ },
+ {
+ "timestamp": "2025-11-10T00:00:00.000Z",
+ "price": 182.4,
+ "volume": 51000000
+ },
+ {
+ "timestamp": "2025-11-11T00:00:00.000Z",
+ "price": 183.6,
+ "volume": 49000000
+ },
+ {
+ "timestamp": "2025-11-12T00:00:00.000Z",
+ "price": 184.0,
+ "volume": 50000000
+ },
+ {
+ "timestamp": "2025-11-13T00:00:00.000Z",
+ "price": 184.75,
+ "volume": 47000000
+ },
+ {
+ "timestamp": "2025-11-14T00:00:00.000Z",
+ "price": 185.5,
+ "volume": 55000000
+ }
+ ]
+}
diff --git a/client/trading-dashboard/cypress/fixtures/tickers.json b/client/trading-dashboard/cypress/fixtures/tickers.json
new file mode 100644
index 0000000..3e87d88
--- /dev/null
+++ b/client/trading-dashboard/cypress/fixtures/tickers.json
@@ -0,0 +1,66 @@
+{
+ "data": {
+ "tickers": [
+ {
+ "symbol": "AAPL",
+ "name": "Apple Inc.",
+ "price": 185.5,
+ "previousClose": 184.0,
+ "change": 1.5,
+ "changePercent": 0.82,
+ "volume": 55000000,
+ "high24h": 186.2,
+ "low24h": 183.4,
+ "lastUpdate": "2025-11-14T15:30:00.000Z"
+ },
+ {
+ "symbol": "TSLA",
+ "name": "Tesla Inc.",
+ "price": 245.75,
+ "previousClose": 250.0,
+ "change": -4.25,
+ "changePercent": -1.7,
+ "volume": 42000000,
+ "high24h": 251.5,
+ "low24h": 244.0,
+ "lastUpdate": "2025-11-14T15:30:00.000Z"
+ },
+ {
+ "symbol": "GOOGL",
+ "name": "Alphabet Inc.",
+ "price": 140.25,
+ "previousClose": 139.5,
+ "change": 0.75,
+ "changePercent": 0.54,
+ "volume": 28000000,
+ "high24h": 141.0,
+ "low24h": 138.8,
+ "lastUpdate": "2025-11-14T15:30:00.000Z"
+ },
+ {
+ "symbol": "MSFT",
+ "name": "Microsoft Corp.",
+ "price": 380.9,
+ "previousClose": 378.25,
+ "change": 2.65,
+ "changePercent": 0.7,
+ "volume": 31000000,
+ "high24h": 382.5,
+ "low24h": 377.0,
+ "lastUpdate": "2025-11-14T15:30:00.000Z"
+ },
+ {
+ "symbol": "BTC-USD",
+ "name": "Bitcoin USD",
+ "price": 43250.5,
+ "previousClose": 42000.0,
+ "change": 1250.5,
+ "changePercent": 2.98,
+ "volume": 15000000,
+ "high24h": 43500.0,
+ "low24h": 41800.0,
+ "lastUpdate": "2025-11-14T15:30:00.000Z"
+ }
+ ]
+ }
+}
diff --git a/client/trading-dashboard/cypress/support/commands.ts b/client/trading-dashboard/cypress/support/commands.ts
new file mode 100644
index 0000000..c38582a
--- /dev/null
+++ b/client/trading-dashboard/cypress/support/commands.ts
@@ -0,0 +1,2 @@
+// custom commands for Cypress can be added here
+// example: Cypress.Commands.add('login', () => { ... })
diff --git a/client/trading-dashboard/cypress/support/e2e.ts b/client/trading-dashboard/cypress/support/e2e.ts
new file mode 100644
index 0000000..f887c29
--- /dev/null
+++ b/client/trading-dashboard/cypress/support/e2e.ts
@@ -0,0 +1 @@
+import "./commands";
diff --git a/client/trading-dashboard/jest.config.ts b/client/trading-dashboard/jest.config.ts
new file mode 100644
index 0000000..a394c80
--- /dev/null
+++ b/client/trading-dashboard/jest.config.ts
@@ -0,0 +1,28 @@
+import type { Config } from "jest";
+import nextJest from "next/jest.js";
+
+const createJestConfig = nextJest({
+ // Provide the path to your Next.js app to load next.config.js and .env files in your test environment
+ dir: "./",
+});
+
+// Add any custom config to be passed to Jest
+const config: Config = {
+ coverageProvider: "v8",
+ testEnvironment: "jsdom",
+ // Setup testing-library
+ setupFilesAfterEnv: ["