diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 1a6d321..93a0fb6 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -4,7 +4,7 @@ on: pull_request: branches: [ "*" ] push: - branches: [ "main", "development", "release/*", "feature-stock-tracking" ] + branches: [ "main", "development", "release/*" ] # Allows you to run this workflow manually from the Actions tab workflow_dispatch: @@ -16,35 +16,5 @@ jobs: working-directory: app/server steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - - name: Build base images - run: docker compose build - - name: Build test images - run: | - docker compose -f docker-compose.yml -f docker-compose.test.yml build - - name: Start backend services - run: docker compose -f docker-compose.yml up -d database auth user analytics market api-gateway - name: Run tests - run: | - failed=() - for service in \ - auth-test \ - user-test \ - analytics-test \ - market-test \ - market-mutation-test \ - market-integration-test - do - echo "::group::Running $service" - if ! docker compose -f docker-compose.yml -f docker-compose.test.yml run --rm "$service"; then - failed+=("$service") - fi - echo "::endgroup::" - done - - if [ ${#failed[@]} -gt 0 ]; then - echo "Failed test services: ${failed[*]}" - exit 1 - fi - - name: Stop services - if: always() - run: docker compose -f docker-compose.yml -f docker-compose.test.yml down -v --remove-orphans + run: ./run_tests.sh diff --git a/app/client/src/App.tsx b/app/client/src/App.tsx index 391861b..d1ef33e 100644 --- a/app/client/src/App.tsx +++ b/app/client/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import { BrowserRouter as Router, Navigate, @@ -13,11 +13,6 @@ import DashboardPage from "./pages/DashboardPage.tsx"; import MarketsPage from "./pages/MarketsPage"; import AppLayout from "./components/AppLayout.tsx"; import ProjectionPage from "./pages/ProjectionPage.tsx"; -import { syncPinnedMarketsResetKey } from "./utils/marketStorage"; -//import { loadSession, saveSession, clearSession } from "./utils/storage.ts"; -//import { requestAuth } from "./api/AuthAPI"; -//import { resolveUserFromToken } from "./utils/token"; -// import type { AuthApiResponse, AuthSession, AuthUser } from "./pages/authTypes"; const SESSION_STORAGE_KEY = "finus-session"; const API_BASE_URL = @@ -142,35 +137,6 @@ function App() { loadSession(), ); - useEffect(() => { - let cancelled = false; - - async function syncServerResetKey() { - try { - const response = await fetch(`${API_BASE_URL}/client-state/reset-key`); - const data = (await response.json().catch(() => null)) as { - resetKey?: unknown; - } | null; - - if (cancelled || !response.ok) { - return; - } - - if (typeof data?.resetKey === "string" && data.resetKey.length > 0) { - syncPinnedMarketsResetKey(data.resetKey); - } - } catch { - // Ignore reset-key sync failures and keep the client usable offline. - } - } - - void syncServerResetKey(); - - return () => { - cancelled = true; - }; - }, []); - function handleAuthSuccess(token: string, fallbackUser: Partial) { const nextSession: AuthSession = { token, diff --git a/app/client/src/utils/marketStorage.ts b/app/client/src/utils/marketStorage.ts index 36a8e74..ecd94f5 100644 --- a/app/client/src/utils/marketStorage.ts +++ b/app/client/src/utils/marketStorage.ts @@ -3,7 +3,6 @@ import { loadSession } from "@/utils/storage"; const PINNED_MARKETS_STORAGE_KEY_PREFIX = "finus-pinned-markets"; const LEGACY_PINNED_MARKETS_STORAGE_KEY = PINNED_MARKETS_STORAGE_KEY_PREFIX; -const SERVER_RESET_KEY_STORAGE_KEY = "finus-server-reset-key"; const PINNED_MARKETS_CLEARED_EVENT = "finus:pinned-markets-cleared"; function resolvePinnedMarketsStorageKey(): string | null { @@ -26,21 +25,6 @@ function clearLegacyPinnedMarkets() { localStorage.removeItem(LEGACY_PINNED_MARKETS_STORAGE_KEY); } -function clearAllPinnedMarkets() { - clearLegacyPinnedMarkets(); - - for (let index = localStorage.length - 1; index >= 0; index -= 1) { - const key = localStorage.key(index); - if (!key) { - continue; - } - - if (key.startsWith(`${PINNED_MARKETS_STORAGE_KEY_PREFIX}:`)) { - localStorage.removeItem(key); - } - } -} - function loadPinnedMarkets(): PinnedMarketInstrument[] { clearLegacyPinnedMarkets(); @@ -84,23 +68,9 @@ function savePinnedMarkets(items: PinnedMarketInstrument[]) { localStorage.setItem(storageKey, JSON.stringify(items)); } -function syncPinnedMarketsResetKey(resetKey: string): boolean { - const previousResetKey = localStorage.getItem(SERVER_RESET_KEY_STORAGE_KEY); - if (previousResetKey === resetKey) { - return false; - } - - clearAllPinnedMarkets(); - localStorage.setItem(SERVER_RESET_KEY_STORAGE_KEY, resetKey); - window.dispatchEvent(new Event(PINNED_MARKETS_CLEARED_EVENT)); - return true; -} - export { - clearAllPinnedMarkets, loadPinnedMarkets, PINNED_MARKETS_CLEARED_EVENT, savePinnedMarkets, PINNED_MARKETS_STORAGE_KEY_PREFIX, - syncPinnedMarketsResetKey, }; diff --git a/app/server/.env b/app/server/.env index 314a0ae..a885f00 100644 --- a/app/server/.env +++ b/app/server/.env @@ -1,6 +1,8 @@ #THIS IS NOT .gitignored'D! #DON'T PUT REAL SECRETS IN HERE!!! +API_GATEWAY_PORT=3000 + #CI DOCKER_HUB_USERNAME=measureonecodetwice RELEASE_VERSION=local diff --git a/app/server/docker-compose.test.yml b/app/server/docker-compose.test.yml index 76428b5..31b564a 100644 --- a/app/server/docker-compose.test.yml +++ b/app/server/docker-compose.test.yml @@ -1,9 +1,6 @@ -#This compose file runs all the tests. -#It depends on the main compose file being built -#as all the test images inherit from the regular images. -#you can use the following command to run the tests -#docker compose build -q && docker compose -f docker-compose.yml -f docker-compose.test.yml build -q && docker compose -f docker-compose.yml -f docker-compose.test.yml up --abort-on-container-failure +#Run with ./run-tests.sh services: + # ------ UNIT TESTS ------ auth-test: build: context: services/ts @@ -19,56 +16,39 @@ services: context: services/python/analytics dockerfile: Dockerfile.test - # user-integration-test:#uncomment to run integration tests locally as it likely does not work properly in CI/CD - # build: - # context: ./services/ts - # dockerfile: user/Dockerfile.integration.test - # env_file: - # - ./default_container.env - # environment: - # - JWT_SECRET - - # depends_on: - # database: - # condition: service_healthy - # api-gateway: - # condition: service_started + market-test: + build: + context: services/python/market + dockerfile: Dockerfile.test + # # ------ MUTATION TESTS ------ # user-mutation-test: # build: # context: ./services/ts # dockerfile: user/Dockerfile.mutation.test - market-test: - build: - context: services/python/market - dockerfile: Dockerfile.test market-mutation-test: build: context: services/python/market dockerfile: Dockerfile.mutation.test + # MISSING + # ANALYTICS + # AUTH + + # ------ INTEGRATION TESTS ------ market-integration-test: build: context: services/python/market dockerfile: Dockerfile.integration.test env_file: - ./default_container.env - depends_on: - market: - condition: service_started - # user-integration-test: - # build: - # context: ./services/ts - # dockerfile: user/Dockerfile.integration.test - # env_file: - # - ./default_container.env - # environment: - # - JWT_SECRET - - # depends_on: - # database: - # condition: service_healthy - # api-gateway: - # condition: service_started + user-integration-test: + build: + context: ./services/ts + dockerfile: user/Dockerfile.integration.test + env_file: + - ./default_container.env + environment: + - JWT_SECRET diff --git a/app/server/docker-compose.yml b/app/server/docker-compose.yml index 906ab34..c5d1445 100644 --- a/app/server/docker-compose.yml +++ b/app/server/docker-compose.yml @@ -9,7 +9,7 @@ services: environment: - NODE_ENV=development ports: - - "3000:8000" + - "$API_GATEWAY_PORT:8000" volumes: - client_state_data:/var/lib/finus-client-state depends_on: diff --git a/app/server/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json b/app/server/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json deleted file mode 100644 index c418db7..0000000 --- a/app/server/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +++ /dev/null @@ -1 +0,0 @@ -{"version":"4.1.1","results":[[":services/ts/user/test/logic_snapshot.test.ts",{"duration":24.460399999999993,"failed":true}],[":services/ts/user/test/dates.test.ts",{"duration":28.86179999999996,"failed":true}],[":services/ts/user/test/logic_expenses.test.ts",{"duration":14.341799999999978,"failed":false}],[":services/ts/auth/test/logic.test.ts",{"duration":0,"failed":true}],[":services/ts/user/test/logic_transactions.test.ts",{"duration":8.949200000000019,"failed":false}],[":services/ts/auth/test/parsing.test.ts",{"duration":0,"failed":true}],[":services/ts/auth/test/validation.test.ts",{"duration":6.108399999999989,"failed":false}]]} \ No newline at end of file diff --git a/app/server/run_tests.sh b/app/server/run_tests.sh new file mode 100755 index 0000000..ea4ddcc --- /dev/null +++ b/app/server/run_tests.sh @@ -0,0 +1,73 @@ +#!/bin/bash + +quitting=false +quit() { + if ! $quitting; then + quitting=true + cleanup + exit 0 + fi +} +cleanup() { + echo "Cleaning up..." + docker compose -f docker-compose.test.yml -f docker-compose.yml down +} +trap quit SIGINT + +#--- Build images --- +echo "Building app images..." +docker compose build -q +status=$? +if [ $status -ne 0 ]; then + echo "Failed to build services" + exit $status +fi + +echo "Building test images..." +docker compose -f docker-compose.test.yml build -q +status=$? +if [ $status -ne 0 ]; then + echo "Failed to build test images" + exit $status +fi + +#--- Launch services --- +docker compose up -d + +#--- Wait for services to be healthy --- +source .env + +is_app_healthy() { + echo $(curl localhost:$API_GATEWAY_PORT/health 2> /dev/null | grep -Pc '^{(\"[^\"]*\":\"ok\",?)*}$') +} + +curr_checks=0 +max_checks=10 +while [[ healthy=$(is_app_healthy) -ne 1 ]] && [ $curr_checks -lt $max_checks ]; do + curr_checks=$(($curr_checks + 1)) + sleep 2.5 + echo "App unhealthy. Retrying ($curr_checks of $max_checks)" +done + +if [ $healthy -ne 1 ]; then + echo "App unhealthy after $max_checks retries. Exiting" + exit 1 +fi + +#--- Run tests --- +docker compose -f docker-compose.test.yml up --abort-on-container-failure +test_status=$? + +if [ $test_status -eq 0 ]; then + echo '+--------------------+' + echo '| ✅ All tests pass! |' + echo '+--------------------+' +else + echo '+--------------------+' + echo '| ❌ Tests failed |' + echo '+--------------------+' + +fi +cleanup + +exit $test_status diff --git a/app/server/services/python/analytics/src/dependencies.py b/app/server/services/python/analytics/src/dependencies.py index ebb1ac0..e64f222 100644 --- a/app/server/services/python/analytics/src/dependencies.py +++ b/app/server/services/python/analytics/src/dependencies.py @@ -9,7 +9,7 @@ SECRET_KEY = os.getenv('JWT_SECRET') ALGORITHM = 'HS256' -def get_db_connection(): +def get_db_connection(timeout: int = 10): #check env vars if not all([os.getenv("MYSQL_HOST"), os.getenv("MYSQL_USER"), os.getenv("MYSQL_PASSWORD"), os.getenv("DB_NAME")]): raise HTTPException(status_code=500, detail="Missing environment variables") @@ -17,7 +17,8 @@ def get_db_connection(): host=os.getenv("MYSQL_HOST"), user=os.getenv("MYSQL_USER"), password=os.getenv("MYSQL_PASSWORD"), - database=os.getenv("DB_NAME") + database=os.getenv("DB_NAME"), + connection_timeout=timeout ) async def get_current_user(authorization: Optional[str] = Header(None)): @@ -35,4 +36,4 @@ async def get_current_user(authorization: Optional[str] = Header(None)): return int(user_id) except Exception as e: - raise HTTPException(status_code=401, detail=str(e))#general exception for simpler unit \ No newline at end of file + raise HTTPException(status_code=401, detail=str(e))#general exception for simpler unit diff --git a/app/server/services/python/analytics/src/main.py b/app/server/services/python/analytics/src/main.py index eb80e9b..c0ada55 100644 --- a/app/server/services/python/analytics/src/main.py +++ b/app/server/services/python/analytics/src/main.py @@ -1,7 +1,8 @@ from fastapi import FastAPI, Depends, Query, HTTPException +from fastapi.responses import PlainTextResponse from fastapi.middleware.cors import CORSMiddleware import pandas as pd -from typing import List, Dict +from typing import List import uvicorn import os @@ -11,7 +12,12 @@ from src.logic.budget import generate_budget, generate_budget_performance from src.queries import savings as savings_queries, incomeflow as incomeflow_queries from src.logic import savings as savings_service, incomeflow as incomeflow_service, debt as debt_service -from src.models.schemas import BadRequestError, ProjectedSavingsRequest, ProjectedSavingsResponse, CompoundInterestResponse, DebtPayoffRequest +from src.models.schemas import ( + BadRequestError, + ProjectedSavingsRequest, + CompoundInterestResponse, + DebtPayoffRequest +) app = FastAPI() @@ -27,50 +33,48 @@ ) - @app.get('/charts/savings') async def get_savings(period: str, user_id: int = Depends(get_current_user)): try: connection = get_db_connection() if not connection: raise HTTPException(status_code=500, detail="Database connection failed") - + cursor = connection.cursor(dictionary=True) - + # Get savings accounts accounts = savings_queries.get_savings_accounts(cursor, user_id) if not accounts: cursor.close() connection.close() return None - + # Get transactions account_ids = [acc['id'] for acc in accounts] transactions = savings_queries.get_savings_transactions(cursor, account_ids) - + cursor.close() connection.close() - + if not transactions: return None - + # Calculate date range end_date = pd.Timestamp.today() - start_date, freq, date_format, period_name = period_calc(period, end_date) - + start_date, _, _, period_name = period_calc(period, end_date) + # Process data result = savings_service.calculate_savings_over_time( accounts, transactions, start_date, end_date, period, period_name ) - + return result - + except Exception as e: print(f"Error: {e}") raise HTTPException(status_code=500, detail=f"Error: {e}") - @app.get('/charts/incomeflow') async def get_income_flow( period: str = Query(default='w', enum=['w', 'm', 'y']), @@ -80,31 +84,30 @@ async def get_income_flow( connection = get_db_connection() if not connection: raise HTTPException(status_code=500, detail="Database connection failed") - + cursor = connection.cursor(dictionary=True) - + # Calculate date range end_date = pd.Timestamp.today() start_date, _, _, _ = period_calc(period, end_date) - + # Get transactions transactions = incomeflow_queries.get_user_transactions(cursor, user_id, start_date, end_date) - + cursor.close() connection.close() - + if not transactions: return None - + # Build sankey data return incomeflow_service.build_sankey_data(transactions) - + except Exception as e: print(f"Error generating income flow: {str(e)}") raise HTTPException(status_code=500, detail=f"Error generating income flow: {str(e)}") - @app.get('/charts/budget-expenditure') async def get_budget( period: str = Query(default='w', enum=['w', 'm', 'y']), @@ -119,37 +122,49 @@ async def get_budget( except Exception as e: print(e) raise HTTPException(status_code=500, detail=str(e)) - + + @app.post('/predict-debt-payoff') async def predict_debt_payoff( requestBody: DebtPayoffRequest, user_id: int = Depends(get_current_user), ): try: - print(f"Generating predicted debt payoff for user {user_id}") - print(requestBody) - return debt_service.generate_debt_payoff_stages(requestBody) + print(f"Generating predicted debt payoff for user {user_id}") + print(requestBody) + return debt_service.generate_debt_payoff_stages(requestBody) except BadRequestError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: print(e) raise HTTPException(status_code=500, detail=str(e)) + @app.post('/compound-interest') async def compount_interest( requestBody: ProjectedSavingsRequest, user_id: int = Depends(get_current_user), ) -> List[CompoundInterestResponse]: try: - print(f"Generating compound interests for user {user_id}") - print(requestBody) - return savings_service.calculate_compound_interest(requestBody) + print(f"Generating compound interests for user {user_id}") + print(requestBody) + return savings_service.calculate_compound_interest(requestBody) except Exception as e: print(e) raise HTTPException(status_code=500, detail=str(e)) +@app.get('/health', response_class=PlainTextResponse) +async def health(): + status = 'ok' + try: + conn = get_db_connection(1) + conn.close() + except Exception: + status = 'unhealthy' + + return status if __name__ == "__main__": port = int(os.getenv("PORT", 8000)) - uvicorn.run(app, port=port) \ No newline at end of file + uvicorn.run(app, port=port) diff --git a/app/server/services/python/analytics/test/dependencies.py b/app/server/services/python/analytics/test/dependencies.py deleted file mode 100644 index df09d1d..0000000 --- a/app/server/services/python/analytics/test/dependencies.py +++ /dev/null @@ -1,76 +0,0 @@ -import pytest -from unittest.mock import patch, MagicMock -import mysql.connector -from src.dependencies import get_db_connection, get_current_user -from fastapi import HTTPException -import os -import jwt -from jwt import InvalidSignatureError - -class TestDatabaseConnection: - - @patch('mysql.connector.connect') - def test_get_db_connection_success(self, mock_connect, mock_env_vars): - mock_connection = MagicMock() - mock_connect.return_value = mock_connection - - result = get_db_connection() - - mock_connect.assert_called_once_with( - host='localhost', - user='test_user', - password='test_password', - database='test_db' - ) - - assert result == mock_connection - - @patch('mysql.connector.connect') - def test_get_db_connection_missing_env_vars(self, mock_connect): - with patch.dict(os.environ, {}, clear=True): - with pytest.raises(HTTPException): - get_db_connection() - - @patch('mysql.connector.connect') - def test_get_db_connection_failure(self, mock_connect): - mock_connect.side_effect = mysql.connector.Error("Connection failed") - with patch.dict(os.environ, {'MYSQL_HOST': 'localhost', 'MYSQL_USER': 'test_user', 'MYSQL_PASSWORD': 'test_password', 'DB_NAME': 'test_db'}, clear=True): - with pytest.raises(mysql.connector.Error): - get_db_connection() - - - -class TestJWTAuthentication: - - @pytest.mark.asyncio - async def test_get_current_user_missing_user_id(self): - - payload = {'email': 'test@example.com'} - token = jwt.encode(payload, 'test_secret_key_12345', algorithm='HS256') - - # Ensure token is string - if isinstance(token, bytes): - token = token.decode('utf-8') - - with patch.dict(os.environ, {'JWT_SECRET': 'test_secret_key_12345'}): - with pytest.raises(HTTPException) as exc_info: - await get_current_user(authorization=f"Bearer {token}") - - assert exc_info.value.status_code == 401 - assert exc_info.value.detail == "User ID not found in token" - - @pytest.mark.asyncio - async def test_get_current_user_invalid_signature(self): - - payload = {'sub': '123'} - token = jwt.encode(payload, 'wrong_secret', algorithm='HS256') - - if isinstance(token, bytes): - token = token.decode('utf-8') - - with patch.dict(os.environ, {'JWT_SECRET': 'test_secret_key_12345'}): - with pytest.raises(HTTPException) as exc_info: - await get_current_user(authorization=f"Bearer {token}") - - assert exc_info.value.status_code == 401 - assert "token" in exc_info.value.detail.lower() \ No newline at end of file diff --git a/app/server/services/python/analytics/test/test_dependencies.py b/app/server/services/python/analytics/test/test_dependencies.py index b148cde..7a32238 100644 --- a/app/server/services/python/analytics/test/test_dependencies.py +++ b/app/server/services/python/analytics/test/test_dependencies.py @@ -21,7 +21,8 @@ def test_get_db_connection_success(self, mock_connect, mock_env_vars): host='localhost', user='test_user', password='test_password', - database='test_db' + database='test_db', + connection_timeout=10 ) assert result == mock_connection @@ -123,4 +124,4 @@ async def test_get_current_user_no_bearer(self): await get_current_user(authorization=f'something {token}') assert exc_info.value.status_code == 401 - # assert exc_info.value.detail == '401: Invalid authentication scheme' \ No newline at end of file + # assert exc_info.value.detail == '401: Invalid authentication scheme' diff --git a/app/server/services/python/market/integration/test_market_integration.py b/app/server/services/python/market/integration/test_market_integration.py index a50b45c..de030b8 100644 --- a/app/server/services/python/market/integration/test_market_integration.py +++ b/app/server/services/python/market/integration/test_market_integration.py @@ -16,7 +16,7 @@ def wait_for_market_service(base_url: str, timeout_seconds: float) -> None: while time.monotonic() < deadline: try: response = httpx.get(f"{base_url}/health", timeout=5.0) - if response.status_code == 200 and response.json() == "ok": + if response.status_code == 200 and response.text == "ok": return last_error = f"unexpected health response: {response.status_code} {response.text}" except httpx.HTTPError as exc: @@ -39,7 +39,7 @@ def test_market_health_endpoint(market_client: httpx.Client) -> None: response = market_client.get("/health") assert response.status_code == 200 - assert response.json() == "ok" + assert response.text == "ok" def test_search_endpoint_returns_enriched_market_results( diff --git a/app/server/services/python/market/src/main.py b/app/server/services/python/market/src/main.py index 2a4c945..c19d8dc 100644 --- a/app/server/services/python/market/src/main.py +++ b/app/server/services/python/market/src/main.py @@ -2,6 +2,7 @@ import uvicorn from fastapi import FastAPI, HTTPException, Query +from fastapi.responses import PlainTextResponse from .config import DEFAULT_SEARCH_LIMIT, SUPPORTED_INTERVALS, SUPPORTED_PERIODS from .service import build_history, enrich_search_results, fetch_quote_snapshot @@ -14,7 +15,7 @@ app = FastAPI() -@app.get("/health") +@app.get('/health', response_class=PlainTextResponse) def test_endpoint(): return "ok" diff --git a/app/server/services/python/market/test/test_main.py b/app/server/services/python/market/test/test_main.py index e5bf976..51832a0 100644 --- a/app/server/services/python/market/test/test_main.py +++ b/app/server/services/python/market/test/test_main.py @@ -14,7 +14,7 @@ def test_health_endpoint(): response = client.get("/health") assert response.status_code == 200 - assert response.json() == "ok" + assert response.text == "ok" def test_search_route_combines_sources_and_enriches_results(): diff --git a/app/server/services/ts/api-gateway/src/index.ts b/app/server/services/ts/api-gateway/src/index.ts index 0313249..5ba37c1 100644 --- a/app/server/services/ts/api-gateway/src/index.ts +++ b/app/server/services/ts/api-gateway/src/index.ts @@ -3,215 +3,79 @@ import { onExit } from "@/hooks"; import express from "express"; import { createProxyMiddleware } from "http-proxy-middleware"; import { buildCorsConfig } from "@/expressUtils.ts"; -import * as fs from "node:fs"; -import * as path from "node:path"; -import { randomUUID } from "node:crypto"; const app = express(); app.use(buildCorsConfig()); -const CLIENT_STATE_DIR = - process.env.CLIENT_STATE_DIR ?? "/var/lib/finus-client-state"; -const CLIENT_STATE_RESET_KEY_PATH = path.join( - CLIENT_STATE_DIR, - "reset-key.txt", -); - -function resolveClientStateResetKey() { - try { - const existingKey = fs - .readFileSync(CLIENT_STATE_RESET_KEY_PATH, "utf8") - .trim(); - if (existingKey.length > 0) { - return existingKey; - } - } catch { - // Fall through and create a new reset key. - } - - const nextKey = randomUUID(); - fs.mkdirSync(CLIENT_STATE_DIR, { recursive: true }); - fs.writeFileSync(CLIENT_STATE_RESET_KEY_PATH, nextKey, "utf8"); - return nextKey; -} - -const clientStateResetKey = resolveClientStateResetKey(); - -const pathMatches = (path, valid) => { - path = path.replace("/api/", ""); +const pathMatches = (path, valid): boolean => { + path = path.replace("/api", ""); return valid.includes(path); }; -const AUTH_PATHS = ["signup", "login"]; -app.use( - createProxyMiddleware({ - pathFilter: (path) => pathMatches(path, AUTH_PATHS), - target: process.env.AUTH_SERVICE_ADDR, - changeOrigin: true, - pathRewrite: { "^/api": "" }, - }), -); +const registerProxy = (target: string, paths: string[]): void => { + app.use( + createProxyMiddleware({ + pathFilter: (path) => pathMatches(path, paths), + target: target, + changeOrigin: true, + pathRewrite: { "^/api": "" }, + }), + ); +}; + +registerProxy(process.env.AUTH_SERVICE_ADDR, ["/signup", "/login"]); -const USER_PATHS = [ - "accounts", - "profiles", +registerProxy(process.env.MARKET_SERVICE_ADDR, [ + "/markets/search", + "/markets/quote", + "/markets/history", +]); + +registerProxy(process.env.USER_SERVICE_ADDR, [ + "/accounts", + "/profiles", + "/goals", + "/debts", + "/transactions", "/charts/expenses", "/table/transactions", "/table/snapshot", - "/goals", -]; -app.use( - createProxyMiddleware({ - pathFilter: (path) => pathMatches(path, USER_PATHS), - target: process.env.USER_SERVICE_ADDR, - changeOrigin: true, - pathRewrite: { "^/api": "" }, - }), -); + "/transactions/csvTransaction", +]); -const MARKET_PATHS = ["markets/search", "markets/quote", "markets/history"]; -app.use( - createProxyMiddleware({ - pathFilter: (path) => pathMatches(path, MARKET_PATHS), - target: process.env.MARKET_SERVICE_ADDR, - changeOrigin: true, - pathRewrite: { "^/api": "" }, - }), -); - -app.use( - createProxyMiddleware({ - pathFilter: "/api/transactions", - target: process.env.USER_SERVICE_ADDR, - changeOrigin: true, - pathRewrite: { "^/api/transactions": "/transactions" }, - }), -); +registerProxy(process.env.ANALYTICS_SERVICE_ADDR, [ + "/charts/savings", + "/charts/incomeflow", + "/charts/budget-expenditure", + "/compound-interest", + "/predict-debt-payoff", +]); app.get("/health", async (req: express.Request, res: express.Response) => { - const result: { [key: string]: string } = {}; const services: string[] = Object.keys(process.env).filter((x) => /^.*_SERVICE_ADDR$/.test(x), ); - for (const service of services) { - const serviceName = service.split("_")[0]; - result[serviceName] = await fetch(`${process.env[service]}/health`) + //parallel arrays + const serviceNames: string[] = services.map( + (service) => service.split("_")[0], + ); + const serviceStatusPromises: [Promise] = services.map((service) => + fetch(`${process.env[service]}/health`) .then((res) => res.text()) - .catch((err) => err.message); - console.log("received response"); - } + .catch((err) => err.message), + ); - res.json(result); -}); + const serviceStatuses = await Promise.all(serviceStatusPromises); + const results = {}; + services.forEach( + (_, index) => (results[serviceNames[index]] = serviceStatuses[index]), + ); -app.get( - "/client-state/reset-key", - (_req: express.Request, res: express.Response) => { - res.json({ resetKey: clientStateResetKey }); - }, -); + res.json(results); +}); const server = app.listen(PORT, () => { console.log(`API Gateway running on port ${PORT}`); }); onExit(async () => await server.close()); - -//expenses bar chart in user service -// app.use( -// createProxyMiddleware({ -// pathFilter: ["/charts/expenses"], -// target: process.env.USER_SERVICE_ADDR, -// changeOrigin: true, -// }), -// ); - -//transactions table in user service -// app.use( -// createProxyMiddleware({ -// pathFilter: ["/table/transactions"], -// target: process.env.USER_SERVICE_ADDR, -// changeOrigin: true, -// }), -// ); - -//snapshot of total values like debt, savings, etc from user service -// app.use( -// createProxyMiddleware({ -// pathFilter: ["/table/snapshot"], -// target: process.env.USER_SERVICE_ADDR, -// changeOrigin: true, -// }), -// ); - -//savings chart from analytics service -app.use( - createProxyMiddleware({ - pathFilter: ["/charts/savings"], - target: process.env.ANALYTICS_SERVICE_ADDR, - changeOrigin: true, - }), -); - -//income flow chart from analytics service - this is the sankey chart -app.use( - createProxyMiddleware({ - pathFilter: ["/charts/incomeflow"], - target: process.env.ANALYTICS_SERVICE_ADDR, - changeOrigin: true, - }), -); - -//budget-expenditure chart from analytics service -app.use( - createProxyMiddleware({ - pathFilter: ["/charts/budget-expenditure"], - target: process.env.ANALYTICS_SERVICE_ADDR, - changeOrigin: true, - }), -); - -//compound interest from analytics service -app.use( - createProxyMiddleware({ - pathFilter: ["/compound-interest"], - target: process.env.ANALYTICS_SERVICE_ADDR, - changeOrigin: true, - }), -); - -//debt payoff prediction from analytics service -app.use( - createProxyMiddleware({ - pathFilter: ["/predict-debt-payoff"], - target: process.env.ANALYTICS_SERVICE_ADDR, - changeOrigin: true, - }), -); - -//debt-related request -app.use( - createProxyMiddleware({ - pathFilter: "/api/debts", - target: process.env.USER_SERVICE_ADDR, - changeOrigin: true, - pathRewrite: { "^/api/debts": "/debts" }, - }), -); -//savings-related request -app.use( - createProxyMiddleware({ - pathFilter: "/api/savings", - target: process.env.USER_SERVICE_ADDR, - changeOrigin: true, - pathRewrite: { "^/api/savings": "/savings" }, - }), -); -// market search, quote, and history endpoints from market service -app.use( - createProxyMiddleware({ - pathFilter: ["/markets/search", "/markets/quote", "/markets/history"], - target: process.env.MARKET_SERVICE_ADDR, - changeOrigin: true, - }), -); diff --git a/app/server/services/ts/auth/bun.lockb b/app/server/services/ts/auth/bun.lockb index c9d0fb0..55281ee 100755 Binary files a/app/server/services/ts/auth/bun.lockb and b/app/server/services/ts/auth/bun.lockb differ diff --git a/app/server/services/ts/auth/src/index.ts b/app/server/services/ts/auth/src/index.ts index 147bc3f..1e664ad 100644 --- a/app/server/services/ts/auth/src/index.ts +++ b/app/server/services/ts/auth/src/index.ts @@ -5,7 +5,7 @@ import { buildCorsConfig, handleServerError } from "@/expressUtils"; import { signup, login } from "./logic"; import type { LoginBody, SignupBody } from "./types"; import { parseLoginBody, parseSignupBody } from "./parsing"; -import { getConnectionPool } from "@/sqlUtil"; +import { getConnectionPool, getDatabaseStatus } from "@/sqlUtil"; const pool = getConnectionPool(); const app = express(); @@ -37,8 +37,9 @@ app.post("/login", async (req, res) => { handleServerError(() => login(body, res, pool), res); }); -app.get("/health", (_, res) => { - res.send({ ok: true }); +app.get("/health", async (_, res) => { + const status = await getDatabaseStatus(); + res.send(status); }); const server = app.listen(PORT, () => { diff --git a/app/server/services/ts/bun.lockb b/app/server/services/ts/bun.lockb index 1e1e37c..c41d30d 100755 Binary files a/app/server/services/ts/bun.lockb and b/app/server/services/ts/bun.lockb differ diff --git a/app/server/services/ts/common/sqlUtil.ts b/app/server/services/ts/common/sqlUtil.ts index dcd7d0d..1b6ae23 100644 --- a/app/server/services/ts/common/sqlUtil.ts +++ b/app/server/services/ts/common/sqlUtil.ts @@ -1,12 +1,37 @@ import mysql from "mysql2/promise"; +const connectionParams = { + host: process.env.MYSQL_HOST, + port: Number(process.env.MYSQL_PORT), + user: process.env.MYSQL_USER, + password: process.env.MYSQL_PASSWORD, +}; + export const getConnectionPool = () => mysql.createPool({ - host: process.env.MYSQL_HOST, - port: Number(process.env.MYSQL_PORT), - user: process.env.MYSQL_USER, - password: process.env.MYSQL_PASSWORD, + ...connectionParams, database: process.env.DB_NAME, waitForConnections: true, connectionLimit: 10, }); + +export type DatabaseStatus = "unhealthy" | "ok"; +export const getDatabaseStatus = async (): DatabaseStatus => { + let status: DatabaseStatus; + + try { + const connection = await mysql.createConnection({ + ...connectionParams, + connectTimeout: 1000, + }); + connection.connect(); + connection.end(); + + status = "ok"; + } catch (e) { + console.error(e.code); + status = "unhealthy"; + } + + return status; +}; diff --git a/app/server/services/ts/common/types.ts b/app/server/services/ts/common/types.ts index c27a677..e8097de 100644 --- a/app/server/services/ts/common/types.ts +++ b/app/server/services/ts/common/types.ts @@ -1,5 +1,7 @@ import type { RowDataPacket } from "mysql2"; +export type DatabaseStatus = "unhealthy" | "ok"; + export type User = { id: number; username: string; diff --git a/app/server/services/ts/user/integration/setup.ts b/app/server/services/ts/user/integration/setup.ts index de537fa..d30dc30 100644 --- a/app/server/services/ts/user/integration/setup.ts +++ b/app/server/services/ts/user/integration/setup.ts @@ -105,7 +105,7 @@ export async function createGoal( goalData: GoalData, ) { const response = await request(baseUrl) - .post("/goals") + .post("/api/goals") .set("Authorization", `Bearer ${token}`) .send(goalData); diff --git a/app/server/services/ts/user/src/DataConversion.ts b/app/server/services/ts/user/src/DataConversion.ts index b5a35f9..cd5fdff 100644 --- a/app/server/services/ts/user/src/DataConversion.ts +++ b/app/server/services/ts/user/src/DataConversion.ts @@ -1,4 +1,3 @@ - //C0j export function convertToDateTime(date: string) { return date.slice(0, 19).replace("T", " "); diff --git a/app/server/services/ts/user/src/index.ts b/app/server/services/ts/user/src/index.ts index 451824c..f968850 100644 --- a/app/server/services/ts/user/src/index.ts +++ b/app/server/services/ts/user/src/index.ts @@ -1,5 +1,5 @@ import express from "express"; -import { pool } from "./db.ts"; +import { getConnectionPool, getDatabaseStatus } from "@/sqlUtil"; import { authenticateJWT } from "./utils/auth.ts"; import { generateDateRange } from "./utils/dates.ts"; import { validatePeriod } from "./validation.ts"; @@ -24,9 +24,9 @@ import { transactionsRouter } from "./routes/transaction.js"; import { debtRouter } from "./routes/debt.ts"; import { savingRouter } from "./routes/saving.ts"; +const pool = getConnectionPool(); const app = express(); app.use(buildCorsConfig()); -onExit(async () => await server.close()); app.use(express.json()); @@ -45,20 +45,11 @@ app.use("/savings", savingRouter); const server = app.listen(PORT, () => { console.log(`User Service running on port ${PORT}`); }); -process.on("SIGTERM", () => cleanup); - - async function cleanup() { - try{ - server.close() - await pool.end() - } catch(error){ - console.log(error) - } -} - -app.get("/charts/expenses", async (req, res) => { -process.on("SIGTERM", () => server.close()); -}) +onExit(async () => { + await new Promise((res) => server.close(res)); + await pool.end(); + process.exit(0); +}); app.get( "/charts/expenses", @@ -307,4 +298,9 @@ app.delete("/goals", async (req: express.Request, res: express.Response) => { } }); +app.get("/health", async (_, res) => { + const status = await getDatabaseStatus(); + res.send(status); +}); + export { generateDateRange }; //for testing purposes only diff --git a/app/server/services/ts/user/src/logic/goals.ts b/app/server/services/ts/user/src/logic/goals.ts index 5fd2f97..fac6085 100644 --- a/app/server/services/ts/user/src/logic/goals.ts +++ b/app/server/services/ts/user/src/logic/goals.ts @@ -169,7 +169,7 @@ export async function createUserGoal( const newGoal = await goalsQueries.createGoal(pool, profileId, goalData); if (!newGoal) { - throw new Error("Failed to create goal"); + throw new Error("Failed to create goal."); } const enrichedGoal = await enrichGoalWithProgress(pool, newGoal, profileId); diff --git a/hooks/format_ts_dir.sh b/hooks/format_ts_dir.sh index a449c26..18fbed8 100755 --- a/hooks/format_ts_dir.sh +++ b/hooks/format_ts_dir.sh @@ -1,12 +1,13 @@ #!/bin/bash #first argument ($1) is the directory to format the files of. -changed_files=$(git diff --cached --name-only --diff-filter=ACMRd | grep -E $1/) +changed_files=$(git diff --cached --name-only --diff-filter=ACMRd | grep -E $1/ | grep -E '\.json|\.ts|\.js^') if [ -z "$changed_files" ]; then exit 0 fi normalized_files=$(echo $changed_files | sed "s|$1/||g" ) cd $1 +echo $normalized_files bun eslint $normalized_files if [[ $? != 0 ]]; then echo "❌ Failed to format files" @@ -14,5 +15,5 @@ if [[ $? != 0 ]]; then fi bun prettier $normalized_files --write --ignore-unknown -git update-index --again +git update-index --again --ignore-missing