From 50ef45cecbd80296510e17cbfac1576d75a2cac8 Mon Sep 17 00:00:00 2001 From: Logan Date: Fri, 27 Mar 2026 12:55:36 -0500 Subject: [PATCH 01/23] removed node modules in server directory --- .../vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json | 1 - 1 file changed, 1 deletion(-) delete mode 100644 app/server/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json 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 From bbb926ee4e6d448f61ff7a59b0cdfcc75fac5447 Mon Sep 17 00:00:00 2001 From: Logan Date: Sun, 29 Mar 2026 14:19:46 -0500 Subject: [PATCH 02/23] re-worked how tests are run --- app/server/.env | 2 + app/server/docker-compose.test.yml | 58 ++--- app/server/docker-compose.yml | 2 +- app/server/run_tests.sh | 60 +++++ .../python/analytics/src/dependencies.py | 7 +- .../services/python/analytics/src/main.py | 75 +++--- app/server/services/python/market/src/main.py | 3 +- .../services/ts/api-gateway/src/index.ts | 229 ++++-------------- app/server/services/ts/auth/bun.lockb | Bin 6418 -> 39919 bytes app/server/services/ts/auth/src/index.ts | 7 +- app/server/services/ts/bun.lockb | Bin 119304 -> 161285 bytes app/server/services/ts/common/sqlUtil.ts | 33 ++- app/server/services/ts/common/types.ts | 2 + .../services/ts/user/src/DataConversion.ts | 1 - app/server/services/ts/user/src/index.ts | 26 +- 15 files changed, 228 insertions(+), 277 deletions(-) create mode 100644 app/server/run_tests.sh 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..f5e85da 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,35 @@ 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 - - # user-mutation-test: - # build: - # context: ./services/ts - # dockerfile: user/Dockerfile.mutation.test 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-mutation-test: build: context: services/python/market dockerfile: Dockerfile.mutation.test + # ------ 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/run_tests.sh b/app/server/run_tests.sh new file mode 100644 index 0000000..ad44b76 --- /dev/null +++ b/app/server/run_tests.sh @@ -0,0 +1,60 @@ +#!/bin/bash + +quitting=false +quit() { + if ! $quitting; then + quitting=true + echo "Cleaning up..." + docker compose -f docker-compose.test.yml -f docker-compose.yml down + exit 0 + fi +} +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=$? + +quit + +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/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/ts/api-gateway/src/index.ts b/app/server/services/ts/api-gateway/src/index.ts index 0313249..079e7c1 100644 --- a/app/server/services/ts/api-gateway/src/index.ts +++ b/app/server/services/ts/api-gateway/src/index.ts @@ -3,215 +3,78 @@ 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) => { +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"]); + +registerProxy(process.env.MARKET_SERVICE_ADDR, [ + "markets/search", + "markets/quote", + "markets/history", +]); -const USER_PATHS = [ +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": "" }, - }), -); - -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 c9d0fb033da37250057cce6ddf83c94391808f4e..55281ee90984df9b02f2f67b91d7adcc99acf8b6 100755 GIT binary patch literal 39919 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 PQJ%ul{=)PBzyJRazw&E; delta 1080 zcmcIj-%Ha`6hGg)t!>|ZzdD<=nTtw-GWEO7)$EsRPhm|>5}Ig*R#Tg819h2#HpE`c zL_K>LLBzm^K@yQl3pMCbq4$D}9wL+;ihe**51qSxXMaF+;Cw&#^SS5V^S$?+`{^m0 zsh(2G=M(n^7iyDj{>4;9`$VWGGne|tu1=i3@>*(YS)cegYJ;qqrvtlhHJNh+pd;4b zogYg8@mV6pgb6|lijS!nBP=A|sbWCIdO{QFeZlaJU@t&uppO&)t0t$120F4lMSLg4 zO%hrxGzjP7pzY3K$+42Yk528)8GRj-0LMiy%K)ws7z7keUSpF0P)Cb2I4#z&q=4fj zz9sS0mbj0hD0x|k;TeKOhU1b~x5)q$ylXnn$}}j-USm6@S7;5xX;Y1HiP!~V0%y#s z#WmT>I<+Vkc+jlZ;#9NtA|(NloDP|_e?j^ikkx%SYH-)y(jXUjmj9j!|?7i1iC2C$`4#&u$aSnfKDmWBoN(GC8tHyF}%Y&vZmiyG4NQ_b&~m{i+u&Wz`} z8Vjhe;FT&B7vZC>W0>r=Z!aL3Px*@ILpH{z${P7KahVszNNyn4lW!x}k!#7RuD~yw zIyzf8UNpWPzjQv%s9W}0@ceb9m^Z$mcDQS1_5BkjTso>a6*umQmEi424XX!y9d>el^w;Mt5l{2Pz} z^hIrK7O*v1&8!-nB$fkv_NCB|MXZh!KQrGwOmNYzE*C&bgKxIt#ASL-+?Fk9AMuGB jDjl+6=SZ0p>WfAOf|vWFJ;4aR7%QD08ST+hC6a#twA;@Z 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 1e1e37c3b08461f7498747bc8ea4e2fa370dbd00..c41d30d8f400b0e1dcc82b6d6f394944bb76729b 100755 GIT binary patch literal 161285 zcmeFad0dTK8$Z0$pczrAXjDprC{i@ih%}fIX`biZtWpxnRE7pBnTgCQ3ejLpltM^U zN=ib?{JSoDujky)dCy^2@B90_e?04R?!J4i`}%&bYh7!&hrQ1^ax#-c>A{noz5FM; z`G-k6hx+rvq3Rpp;^Xe+>!#`+5bWm^q8g^oJB&u7MJ{aI;gu6;9WzOxBHemi{_-i! zV)y3Te>Qf1GW|1GK|yFJTG41b*?s|yn*sm9IA;B73e$jDr_naRCvh4r4xImLO9der zsM7%@0T%;~2h;%^2PnxzPCzlJcY)woz&a*g1{8(*Q9xlpjA#tt8o<$j6PfMo5aKAP zI{@+mhJ^b2yZO>+-gH$zS4|qt)s3#^O{dWec^Et|e^0kyuMm0=v|$+zB!1v+1VsH+ zfFl8y0}29u1{45P03(d&H6NpH3+*5oHwM~;0676i0Dgk=h>d{oPh1`RLA#Y8hJHK( z0$tIsrxQIO98%Q{I`B`NH~hhV^bKd&h0uMyoWbrb)REr;h<0>0zc9C88Z9^idqtxK z1_yWqyV2>7m^^n7!FU6mLOfM{+#)YRJLZ2MAm%p-5c3_xtVad>3WsI|TIi=7l8f<+ zK{BxaE?|ea1Q6qQi*R#s0^40MDA>RCfIx|hX4dI|s1xWEOm|cDqkA*!4uHtF2E_PG z0kJ=iA*mRbyO&2O^n(@#6�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 literal 119304 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

kU{jf)gJd2J>o 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 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/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..1590318 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,6 +24,7 @@ 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()); @@ -47,18 +48,18 @@ const server = app.listen(PORT, () => { }); process.on("SIGTERM", () => cleanup); - async function cleanup() { - try{ - server.close() - await pool.end() - } catch(error){ - console.log(error) +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()); -}) +app.get("/charts/expenses", async () => { + process.on("SIGTERM", () => server.close()); +}); app.get( "/charts/expenses", @@ -307,4 +308,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 From 9c858195b252d7cbfb3b2fe94ab6a4009f8a922d Mon Sep 17 00:00:00 2001 From: Logan Date: Sun, 29 Mar 2026 14:20:06 -0500 Subject: [PATCH 03/23] modification to formatting hook --- hooks/format_ts_dir.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 From ac032c29fd5a915d8fd15125f09dbca80122b30a Mon Sep 17 00:00:00 2001 From: Logan Date: Fri, 27 Mar 2026 12:55:36 -0500 Subject: [PATCH 04/23] removed node modules in server directory --- .../vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json | 1 - 1 file changed, 1 deletion(-) delete mode 100644 app/server/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json 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 From d10dbab42c1f325d9e290dffa60d8c93d2ad7ff9 Mon Sep 17 00:00:00 2001 From: Logan Date: Sun, 29 Mar 2026 19:59:05 -0500 Subject: [PATCH 05/23] updated workflow runner --- .github/workflows/run_tests.yml | 34 ++------------------------------- 1 file changed, 2 insertions(+), 32 deletions(-) diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 1a6d321..cdd7b53 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 From 79fc8e5678e42d7e97978d2f1274d88c97346dce Mon Sep 17 00:00:00 2001 From: Logan Date: Mon, 30 Mar 2026 14:30:32 -0500 Subject: [PATCH 06/23] fixed tests, removed duplicate test file --- app/server/docker-compose.test.yml | 18 ++--- app/server/run_tests.sh | 19 ++++- .../python/analytics/test/dependencies.py | 76 ------------------- .../analytics/test/test_dependencies.py | 5 +- .../integration/test_market_integration.py | 4 +- .../services/python/market/test/test_main.py | 2 +- .../services/ts/api-gateway/src/index.ts | 28 ++++--- .../services/ts/user/integration/setup.ts | 2 +- app/server/services/ts/user/src/index.ts | 18 +---- .../services/ts/user/src/logic/goals.ts | 4 +- 10 files changed, 55 insertions(+), 121 deletions(-) delete mode 100644 app/server/services/python/analytics/test/dependencies.py diff --git a/app/server/docker-compose.test.yml b/app/server/docker-compose.test.yml index f5e85da..76508f8 100644 --- a/app/server/docker-compose.test.yml +++ b/app/server/docker-compose.test.yml @@ -21,16 +21,16 @@ services: context: services/python/market dockerfile: Dockerfile.test - # ------ MUTATION TESTS ------ - user-mutation-test: - build: - context: ./services/ts - dockerfile: user/Dockerfile.mutation.test + # # ------ MUTATION TESTS ------ + # user-mutation-test: + # build: + # context: ./services/ts + # dockerfile: user/Dockerfile.mutation.test - market-mutation-test: - build: - context: services/python/market - dockerfile: Dockerfile.mutation.test + # market-mutation-test: + # build: + # context: services/python/market + # dockerfile: Dockerfile.mutation.test # ------ INTEGRATION TESTS ------ market-integration-test: diff --git a/app/server/run_tests.sh b/app/server/run_tests.sh index ad44b76..ea4ddcc 100644 --- a/app/server/run_tests.sh +++ b/app/server/run_tests.sh @@ -4,11 +4,14 @@ quitting=false quit() { if ! $quitting; then quitting=true - echo "Cleaning up..." - docker compose -f docker-compose.test.yml -f docker-compose.yml down + 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 --- @@ -55,6 +58,16 @@ fi docker compose -f docker-compose.test.yml up --abort-on-container-failure test_status=$? -quit +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/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/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 079e7c1..db080cd 100644 --- a/app/server/services/ts/api-gateway/src/index.ts +++ b/app/server/services/ts/api-gateway/src/index.ts @@ -8,8 +8,13 @@ const app = express(); app.use(buildCorsConfig()); const pathMatches = (path, valid): boolean => { - path = path.replace("/api/", ""); - return valid.includes(path); + console.log(`Checking ${path} against ${valid}`) + const pathBefore = path; + path = path.replace("/api", ""); + console.log(`${pathBefore} -> ${path}`) + const matches = valid.includes(path); + console.log(`matches: ${matches}`); + return matches; }; const registerProxy = (target: string, paths: string[]): void => { @@ -23,23 +28,24 @@ const registerProxy = (target: string, paths: string[]): void => { ); }; -registerProxy(process.env.AUTH_SERVICE_ADDR, ["signup", "login"]); +registerProxy(process.env.AUTH_SERVICE_ADDR, ["/signup", "/login"]); registerProxy(process.env.MARKET_SERVICE_ADDR, [ - "markets/search", - "markets/quote", - "markets/history", + "/markets/search", + "/markets/quote", + "/markets/history", ]); registerProxy(process.env.USER_SERVICE_ADDR, [ - "accounts", - "profiles", - "goals", - "debts", - "transactions", + "/accounts", + "/profiles", + "/goals", + "/debts", + "/transactions", "/charts/expenses", "/table/transactions", "/table/snapshot", + "/transactions/csvTransaction" ]); registerProxy(process.env.ANALYTICS_SERVICE_ADDR, [ 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/index.ts b/app/server/services/ts/user/src/index.ts index 1590318..f968850 100644 --- a/app/server/services/ts/user/src/index.ts +++ b/app/server/services/ts/user/src/index.ts @@ -27,7 +27,6 @@ import { savingRouter } from "./routes/saving.ts"; const pool = getConnectionPool(); const app = express(); app.use(buildCorsConfig()); -onExit(async () => await server.close()); app.use(express.json()); @@ -46,19 +45,10 @@ 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 () => { - process.on("SIGTERM", () => server.close()); +onExit(async () => { + await new Promise((res) => server.close(res)); + await pool.end(); + process.exit(0); }); app.get( diff --git a/app/server/services/ts/user/src/logic/goals.ts b/app/server/services/ts/user/src/logic/goals.ts index 5fd2f97..5f494ea 100644 --- a/app/server/services/ts/user/src/logic/goals.ts +++ b/app/server/services/ts/user/src/logic/goals.ts @@ -168,8 +168,8 @@ export async function createUserGoal( validateGoalInput(goalData); const newGoal = await goalsQueries.createGoal(pool, profileId, goalData); - if (!newGoal) { - throw new Error("Failed to create goal"); + if(!newGoal) { + throw new Error("Failed to create goal."); } const enrichedGoal = await enrichGoalWithProgress(pool, newGoal, profileId); From fa2cfe23351de74dc30beb2a3ad573509f476850 Mon Sep 17 00:00:00 2001 From: Logan Date: Mon, 30 Mar 2026 14:32:51 -0500 Subject: [PATCH 07/23] cleanup test logging --- app/server/services/ts/api-gateway/src/index.ts | 9 ++------- app/server/services/ts/user/src/logic/goals.ts | 2 +- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/app/server/services/ts/api-gateway/src/index.ts b/app/server/services/ts/api-gateway/src/index.ts index db080cd..5ba37c1 100644 --- a/app/server/services/ts/api-gateway/src/index.ts +++ b/app/server/services/ts/api-gateway/src/index.ts @@ -8,13 +8,8 @@ const app = express(); app.use(buildCorsConfig()); const pathMatches = (path, valid): boolean => { - console.log(`Checking ${path} against ${valid}`) - const pathBefore = path; path = path.replace("/api", ""); - console.log(`${pathBefore} -> ${path}`) - const matches = valid.includes(path); - console.log(`matches: ${matches}`); - return matches; + return valid.includes(path); }; const registerProxy = (target: string, paths: string[]): void => { @@ -45,7 +40,7 @@ registerProxy(process.env.USER_SERVICE_ADDR, [ "/charts/expenses", "/table/transactions", "/table/snapshot", - "/transactions/csvTransaction" + "/transactions/csvTransaction", ]); registerProxy(process.env.ANALYTICS_SERVICE_ADDR, [ diff --git a/app/server/services/ts/user/src/logic/goals.ts b/app/server/services/ts/user/src/logic/goals.ts index 5f494ea..fac6085 100644 --- a/app/server/services/ts/user/src/logic/goals.ts +++ b/app/server/services/ts/user/src/logic/goals.ts @@ -168,7 +168,7 @@ export async function createUserGoal( validateGoalInput(goalData); const newGoal = await goalsQueries.createGoal(pool, profileId, goalData); - if(!newGoal) { + if (!newGoal) { throw new Error("Failed to create goal."); } From 5bacecef7f940acb2a38abd4830eee951774d88c Mon Sep 17 00:00:00 2001 From: Logan Date: Mon, 30 Mar 2026 18:00:02 -0500 Subject: [PATCH 08/23] commit to test pipeline --- test.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 test.txt diff --git a/test.txt b/test.txt new file mode 100644 index 0000000..bed136a --- /dev/null +++ b/test.txt @@ -0,0 +1 @@ +aoeu From a0abdde52095e3429f454a126f711fd8ed0f2340 Mon Sep 17 00:00:00 2001 From: Logan Date: Tue, 31 Mar 2026 14:55:55 -0500 Subject: [PATCH 09/23] remove server reset key & change run test action targets --- .github/workflows/run_tests.yml | 2 +- app/client/src/App.tsx | 36 +-------------------------- app/client/src/utils/marketStorage.ts | 30 ---------------------- app/server/docker-compose.test.yml | 12 ++++++--- test.txt | 1 - 5 files changed, 10 insertions(+), 71 deletions(-) delete mode 100644 test.txt diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index cdd7b53..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/*", "*" ] + branches: [ "main", "development", "release/*" ] # Allows you to run this workflow manually from the Actions tab workflow_dispatch: 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/docker-compose.test.yml b/app/server/docker-compose.test.yml index 76508f8..31b564a 100644 --- a/app/server/docker-compose.test.yml +++ b/app/server/docker-compose.test.yml @@ -27,10 +27,14 @@ services: # context: ./services/ts # dockerfile: user/Dockerfile.mutation.test - # market-mutation-test: - # build: - # context: services/python/market - # dockerfile: Dockerfile.mutation.test + market-mutation-test: + build: + context: services/python/market + dockerfile: Dockerfile.mutation.test + + # MISSING + # ANALYTICS + # AUTH # ------ INTEGRATION TESTS ------ market-integration-test: diff --git a/test.txt b/test.txt deleted file mode 100644 index bed136a..0000000 --- a/test.txt +++ /dev/null @@ -1 +0,0 @@ -aoeu From 0c7d81686fbcd462e00713bf25dd2f3860f38609 Mon Sep 17 00:00:00 2001 From: Logan Date: Tue, 31 Mar 2026 15:01:09 -0500 Subject: [PATCH 10/23] made run_tests.sh executable --- app/server/run_tests.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 app/server/run_tests.sh diff --git a/app/server/run_tests.sh b/app/server/run_tests.sh old mode 100644 new mode 100755 From 3bdbc3765f404076b9271a1044404449bdb6ba9b Mon Sep 17 00:00:00 2001 From: Huy Truong Date: Tue, 31 Mar 2026 21:02:00 -0500 Subject: [PATCH 11/23] Server: Added integration tests for auth --- app/server/docker-compose.test.yml | 7 + .../python/analytics/src/logic/savings.py | 6 +- .../ts/auth/Dockerfile.integration.test | 10 + app/server/services/ts/auth/bun.lock | 41 +++++ .../services/ts/auth/integration/auth.test.ts | 172 ++++++++++++++++++ app/server/services/ts/auth/package.json | 3 +- app/server/services/ts/bun.lock | 41 +++++ app/server/services/ts/package.json | 1 + 8 files changed, 277 insertions(+), 4 deletions(-) create mode 100644 app/server/services/ts/auth/Dockerfile.integration.test create mode 100644 app/server/services/ts/auth/integration/auth.test.ts diff --git a/app/server/docker-compose.test.yml b/app/server/docker-compose.test.yml index 31b564a..8c620d9 100644 --- a/app/server/docker-compose.test.yml +++ b/app/server/docker-compose.test.yml @@ -52,3 +52,10 @@ services: - ./default_container.env environment: - JWT_SECRET + + auth-integration-test: + build: + context: ./services/ts + dockerfile: auth/Dockerfile.integration.test + env_file: + - ./default_container.env diff --git a/app/server/services/python/analytics/src/logic/savings.py b/app/server/services/python/analytics/src/logic/savings.py index 43166dd..1e9da07 100644 --- a/app/server/services/python/analytics/src/logic/savings.py +++ b/app/server/services/python/analytics/src/logic/savings.py @@ -163,9 +163,9 @@ def calculate_compound_interest(request: ProjectedSavingsRequest) -> List[Compou monthly_savings_rate = generate_savings_growth_rate(transactions) print(monthly_savings_rate) - best_rate = monthly_savings_rate.best_case - worst_rate = monthly_savings_rate.worst_case - expected_rate = monthly_savings_rate.expected_case + best_rate = round(monthly_savings_rate.best_case / 12, 3) + worst_rate = round(monthly_savings_rate.worst_case / 12, 3) + expected_rate = round(monthly_savings_rate.expected_case / 12, 3) worst_balance = request.balance expected_balance = request.balance diff --git a/app/server/services/ts/auth/Dockerfile.integration.test b/app/server/services/ts/auth/Dockerfile.integration.test new file mode 100644 index 0000000..97fb570 --- /dev/null +++ b/app/server/services/ts/auth/Dockerfile.integration.test @@ -0,0 +1,10 @@ +FROM measureonecodetwice/finus-auth:local + +COPY auth/integration ./integration + +WORKDIR /project +COPY vitest.config.ts . + +ENV CI=true + +CMD ["bun", "test", "integration", "--coverage"] diff --git a/app/server/services/ts/auth/bun.lock b/app/server/services/ts/auth/bun.lock index 5ac2958..6ea5372 100644 --- a/app/server/services/ts/auth/bun.lock +++ b/app/server/services/ts/auth/bun.lock @@ -13,10 +13,15 @@ "devDependencies": { "@types/bun": "^1.3.9", "@types/express": "^5.0.6", + "supertest": "^7.2.2", }, }, }, "packages": { + "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + + "@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.3.1", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw=="], + "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], @@ -45,6 +50,10 @@ "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="], + + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], @@ -59,6 +68,10 @@ "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + + "component-emitter": ["component-emitter@1.3.1", "", {}, "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ=="], + "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], @@ -67,12 +80,18 @@ "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + "cookiejar": ["cookiejar@2.1.4", "", {}, "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "dezalgo": ["dezalgo@1.0.4", "", { "dependencies": { "asap": "^2.0.0", "wrappy": "1" } }, "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], @@ -87,14 +106,22 @@ "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + + "formidable": ["formidable@3.5.4", "", { "dependencies": { "@paralleldrive/cuid2": "^2.2.2", "dezalgo": "^1.0.4", "once": "^1.4.0" } }, "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug=="], + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], @@ -111,6 +138,8 @@ "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], @@ -155,6 +184,10 @@ "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + "methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="], + + "mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="], + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], @@ -211,6 +244,10 @@ "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "superagent": ["superagent@10.3.0", "", { "dependencies": { "component-emitter": "^1.3.1", "cookiejar": "^2.1.4", "debug": "^4.3.7", "fast-safe-stringify": "^2.1.1", "form-data": "^4.0.5", "formidable": "^3.5.4", "methods": "^1.1.2", "mime": "2.6.0", "qs": "^6.14.1" } }, "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ=="], + + "supertest": ["supertest@7.2.2", "", { "dependencies": { "cookie-signature": "^1.2.2", "methods": "^1.1.2", "superagent": "^10.3.0" } }, "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], @@ -222,5 +259,9 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], } } diff --git a/app/server/services/ts/auth/integration/auth.test.ts b/app/server/services/ts/auth/integration/auth.test.ts new file mode 100644 index 0000000..1b969f9 --- /dev/null +++ b/app/server/services/ts/auth/integration/auth.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import request from "supertest"; + +const BASE_URL = process.env.API_GATEWAY_ADDR; + +describe("Authentication Integration (Docker)", () => { + let email: string; + let password: string; + const signupEndpoint = "/api/signup"; + const loginEndpoint = "/api/login"; + + beforeAll(() => { + const unique = `auth${Date.now()}${Math.random().toString(36).slice(2, 8)}`; + email = `${unique}@example.com`; + password = "12idontknow"; + }); + + it("signs up a new user", async () => { + const signupBody = { + username: "anything", + email: email, + first_name: "Auth", + last_name: "Test", + age: 40, + password: password, + } + console.log("Signup body:", signupBody); + const res = await request(BASE_URL) + .post(signupEndpoint) + .send(signupBody) + + + expect(res.status).toBe(201); + }); + + it("failed signup due to duplicate email", async () => { + const signupBody = { + username: "anything", + email: email, + first_name: "Auth", + last_name: "Test", + age: 40, + password: "12idontknow", + } + const res = await request(BASE_URL) + .post(signupEndpoint) + .send(signupBody) + + expect(res.status).toBe(409); + expect(res.body.error).toBe("An account with this email already exists."); + }); + + it("failed signup due to invalid email format", async () => { + const signupBody = { + username: "anything", + email: "invalidemail", + first_name: "Auth", + last_name: "Test", + age: 40, + password: "12idontknow", + } + const res = await request(BASE_URL) + .post(signupEndpoint) + .send(signupBody) + + expect(res.status).toBe(400); + expect(res.body.error).toBe("invalid email format"); + }); + + it("failed signup due to invalid password", async () => { + const signupBody = { + username: "anything", + email: "newaccount@example.com", + first_name: "Auth", + last_name: "Test", + age: 40, + password: "short", + } + const res = await request(BASE_URL) + .post(signupEndpoint) + .send(signupBody) + + expect(res.status).toBe(400); + expect(res.body.error).toBe("Password must be at least 8 characters and include letters and numbers."); + }); + + it("failed signup due to under age", async () => { + const signupBody = { + username: "anything", + email: "newaccount@example.com", + first_name: "Auth", + last_name: "Test", + age: 15, + password: "longenoughpassword1", + } + const res = await request(BASE_URL) + .post(signupEndpoint) + .send(signupBody) + + expect(res.status).toBe(400); + expect(res.body.error).toBe("Age must be a whole number between 16 and 120"); + }); + + it("failed signup due to over age", async () => { + const signupBody = { + username: "anything", + email: "newaccount@example.com", + first_name: "Auth", + last_name: "Test", + age: 121, + password: "longenoughpassword1", + } + const res = await request(BASE_URL) + .post(signupEndpoint) + .send(signupBody) + + expect(res.status).toBe(400); + expect(res.body.error).toBe("Age must be a whole number between 16 and 120"); + }); + + it("logs in an existing user", async () => { + const loginBody = { + email: email, + password: password, + } + const res = await request(BASE_URL) + .post(loginEndpoint) + .send(loginBody) + + expect(res.status).toBe(200); + expect(res.body.token).toBeDefined(); + }); + + it("logs in with wrong password", async () => { + const loginBody = { + email: email, + password: "wrongpassword", + } + const res = await request(BASE_URL) + .post(loginEndpoint) + .send(loginBody) + + expect(res.status).toBe(401); + expect(res.body.error).toBe("Invalid email or password."); + }); + + it("logs into non existing account", async () => { + const loginBody = { + email: "nonexist@exmaple.com", + password: "bskdfks312", + } + const res = await request(BASE_URL) + .post(loginEndpoint) + .send(loginBody) + + expect(res.status).toBe(401); + expect(res.body.error).toBe("Invalid email or password."); + }); + + it("logs in with empty credentials", async () => { + const loginBody = { + email: "", + password: "", + } + const res = await request(BASE_URL) + .post(loginEndpoint) + .send(loginBody) + + expect(res.status).toBe(400); + expect(res.body.error).toBe("Missing email"); + }); +}); \ No newline at end of file diff --git a/app/server/services/ts/auth/package.json b/app/server/services/ts/auth/package.json index 0a28cb8..cad11b2 100644 --- a/app/server/services/ts/auth/package.json +++ b/app/server/services/ts/auth/package.json @@ -15,6 +15,7 @@ }, "devDependencies": { "@types/bun": "^1.3.9", - "@types/express": "^5.0.6" + "@types/express": "^5.0.6", + "supertest": "^7.2.2" } } diff --git a/app/server/services/ts/bun.lock b/app/server/services/ts/bun.lock index ad93995..7455634 100644 --- a/app/server/services/ts/bun.lock +++ b/app/server/services/ts/bun.lock @@ -21,6 +21,7 @@ "jiti": "^2.6.1", "minimatch": "5", "prettier": "3.8.1", + "supertest": "^7.2.2", "typescript-eslint": "^8.55.0", "vitest": "^4.0.18", }, @@ -224,6 +225,8 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/8IzqSu4/OWGRs7Fs2ROzGVwJMFTBQkgAp6sAthkBYoN7OiM4rY/CpPVs2X9w9N1W61CHSkEdNKi8HrLZKfK3g=="], "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-TT7eUihnAzxM2tlZesusuC75PAOYKvUBgVU/Nm/lakZ/DpyuqhNkzUfcxSgmmK9IjVWzMmezLIGZl16XGCGJng=="], @@ -248,6 +251,8 @@ "@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.11", "", { "os": "win32", "cpu": "x64" }, "sha512-cCsXK9AQ9Zf18QlVnbrFu2IKfr4sf2sfbErkF2jfCzyCO9Bnhl0KRx63zlN+Ni1xU7gcBLAssgcui5R400N2eA=="], + "@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.3.1", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="], "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="], @@ -372,10 +377,14 @@ "angular-html-parser": ["angular-html-parser@10.4.0", "", {}, "sha512-++nLNyZwRfHqFh7akH5Gw/JYizoFlMRz0KRigfwfsLqV8ZqlcVRb1LkPEWdYvEKDnbktknM2J4BXaYUGrQZPww=="], + "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg=="], + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], @@ -406,8 +415,12 @@ "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "component-emitter": ["component-emitter@1.3.1", "", {}, "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ=="], + "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], @@ -418,6 +431,8 @@ "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + "cookiejar": ["cookiejar@2.1.4", "", {}, "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw=="], + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -426,12 +441,16 @@ "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], "des.js": ["des.js@1.1.0", "", { "dependencies": { "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0" } }, "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg=="], + "dezalgo": ["dezalgo@1.0.4", "", { "dependencies": { "asap": "^2.0.0", "wrappy": "1" } }, "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig=="], + "diff-match-patch": ["diff-match-patch@1.0.5", "", {}, "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], @@ -452,6 +471,8 @@ "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -492,6 +513,8 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], @@ -516,6 +539,10 @@ "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + + "formidable": ["formidable@3.5.4", "", { "dependencies": { "@paralleldrive/cuid2": "^2.2.2", "dezalgo": "^1.0.4", "once": "^1.4.0" } }, "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug=="], + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], @@ -546,6 +573,8 @@ "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], @@ -636,6 +665,10 @@ "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + "methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="], + + "mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="], + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], @@ -778,6 +811,10 @@ "stryker-mutator-bun-runner": ["stryker-mutator-bun-runner@0.4.0", "", { "dependencies": { "@stryker-mutator/api": "^9.0.1", "@stryker-mutator/util": "^9.0.1", "execa": "^9.6.0", "glob": "^11.0.3", "semver": "^7.5.4" }, "peerDependencies": { "@stryker-mutator/core": "^9.0.0", "bun": ">=1.0.0" } }, "sha512-wx2nfdfKFWRxE5fiAYzRBVJoUMtoeYIE2uAtWS1fu4rGeay6XVm3wBmqi2bKosVH6zHPShsciSCDKDGifZHvSA=="], + "superagent": ["superagent@10.3.0", "", { "dependencies": { "component-emitter": "^1.3.1", "cookiejar": "^2.1.4", "debug": "^4.3.7", "fast-safe-stringify": "^2.1.1", "form-data": "^4.0.5", "formidable": "^3.5.4", "methods": "^1.1.2", "mime": "2.6.0", "qs": "^6.14.1" } }, "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ=="], + + "supertest": ["supertest@7.2.2", "", { "dependencies": { "cookie-signature": "^1.2.2", "methods": "^1.1.2", "superagent": "^10.3.0" } }, "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA=="], + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], @@ -884,6 +921,8 @@ "eslint/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], + "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "glob/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], @@ -900,6 +939,8 @@ "eslint/minimatch/brace-expansion": ["brace-expansion@5.0.3", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA=="], + "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "glob/minimatch/brace-expansion": ["brace-expansion@5.0.3", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA=="], "@eslint/config-array/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], diff --git a/app/server/services/ts/package.json b/app/server/services/ts/package.json index e8d135a..758b166 100644 --- a/app/server/services/ts/package.json +++ b/app/server/services/ts/package.json @@ -13,6 +13,7 @@ "jiti": "^2.6.1", "minimatch": "5", "prettier": "3.8.1", + "supertest": "^7.2.2", "typescript-eslint": "^8.55.0", "vitest": "^4.0.18" }, From 9078752aef4c59b046037e7c54f7a9ec3bd0c8e6 Mon Sep 17 00:00:00 2001 From: Huy Truong Date: Tue, 31 Mar 2026 21:03:34 -0500 Subject: [PATCH 12/23] Formatted --- .../services/ts/auth/integration/auth.test.ts | 317 +++++++++--------- 1 file changed, 151 insertions(+), 166 deletions(-) diff --git a/app/server/services/ts/auth/integration/auth.test.ts b/app/server/services/ts/auth/integration/auth.test.ts index 1b969f9..0823067 100644 --- a/app/server/services/ts/auth/integration/auth.test.ts +++ b/app/server/services/ts/auth/integration/auth.test.ts @@ -4,169 +4,154 @@ import request from "supertest"; const BASE_URL = process.env.API_GATEWAY_ADDR; describe("Authentication Integration (Docker)", () => { - let email: string; - let password: string; - const signupEndpoint = "/api/signup"; - const loginEndpoint = "/api/login"; - - beforeAll(() => { - const unique = `auth${Date.now()}${Math.random().toString(36).slice(2, 8)}`; - email = `${unique}@example.com`; - password = "12idontknow"; - }); - - it("signs up a new user", async () => { - const signupBody = { - username: "anything", - email: email, - first_name: "Auth", - last_name: "Test", - age: 40, - password: password, - } - console.log("Signup body:", signupBody); - const res = await request(BASE_URL) - .post(signupEndpoint) - .send(signupBody) - - - expect(res.status).toBe(201); - }); - - it("failed signup due to duplicate email", async () => { - const signupBody = { - username: "anything", - email: email, - first_name: "Auth", - last_name: "Test", - age: 40, - password: "12idontknow", - } - const res = await request(BASE_URL) - .post(signupEndpoint) - .send(signupBody) - - expect(res.status).toBe(409); - expect(res.body.error).toBe("An account with this email already exists."); - }); - - it("failed signup due to invalid email format", async () => { - const signupBody = { - username: "anything", - email: "invalidemail", - first_name: "Auth", - last_name: "Test", - age: 40, - password: "12idontknow", - } - const res = await request(BASE_URL) - .post(signupEndpoint) - .send(signupBody) - - expect(res.status).toBe(400); - expect(res.body.error).toBe("invalid email format"); - }); - - it("failed signup due to invalid password", async () => { - const signupBody = { - username: "anything", - email: "newaccount@example.com", - first_name: "Auth", - last_name: "Test", - age: 40, - password: "short", - } - const res = await request(BASE_URL) - .post(signupEndpoint) - .send(signupBody) - - expect(res.status).toBe(400); - expect(res.body.error).toBe("Password must be at least 8 characters and include letters and numbers."); - }); - - it("failed signup due to under age", async () => { - const signupBody = { - username: "anything", - email: "newaccount@example.com", - first_name: "Auth", - last_name: "Test", - age: 15, - password: "longenoughpassword1", - } - const res = await request(BASE_URL) - .post(signupEndpoint) - .send(signupBody) - - expect(res.status).toBe(400); - expect(res.body.error).toBe("Age must be a whole number between 16 and 120"); - }); - - it("failed signup due to over age", async () => { - const signupBody = { - username: "anything", - email: "newaccount@example.com", - first_name: "Auth", - last_name: "Test", - age: 121, - password: "longenoughpassword1", - } - const res = await request(BASE_URL) - .post(signupEndpoint) - .send(signupBody) - - expect(res.status).toBe(400); - expect(res.body.error).toBe("Age must be a whole number between 16 and 120"); - }); - - it("logs in an existing user", async () => { - const loginBody = { - email: email, - password: password, - } - const res = await request(BASE_URL) - .post(loginEndpoint) - .send(loginBody) - - expect(res.status).toBe(200); - expect(res.body.token).toBeDefined(); - }); - - it("logs in with wrong password", async () => { - const loginBody = { - email: email, - password: "wrongpassword", - } - const res = await request(BASE_URL) - .post(loginEndpoint) - .send(loginBody) - - expect(res.status).toBe(401); - expect(res.body.error).toBe("Invalid email or password."); - }); - - it("logs into non existing account", async () => { - const loginBody = { - email: "nonexist@exmaple.com", - password: "bskdfks312", - } - const res = await request(BASE_URL) - .post(loginEndpoint) - .send(loginBody) - - expect(res.status).toBe(401); - expect(res.body.error).toBe("Invalid email or password."); - }); - - it("logs in with empty credentials", async () => { - const loginBody = { - email: "", - password: "", - } - const res = await request(BASE_URL) - .post(loginEndpoint) - .send(loginBody) - - expect(res.status).toBe(400); - expect(res.body.error).toBe("Missing email"); - }); -}); \ No newline at end of file + let email: string; + let password: string; + const signupEndpoint = "/api/signup"; + const loginEndpoint = "/api/login"; + + beforeAll(() => { + const unique = `auth${Date.now()}${Math.random().toString(36).slice(2, 8)}`; + email = `${unique}@example.com`; + password = "12idontknow"; + }); + + it("signs up a new user", async () => { + const signupBody = { + username: "anything", + email: email, + first_name: "Auth", + last_name: "Test", + age: 40, + password: password, + }; + console.log("Signup body:", signupBody); + const res = await request(BASE_URL).post(signupEndpoint).send(signupBody); + + expect(res.status).toBe(201); + }); + + it("failed signup due to duplicate email", async () => { + const signupBody = { + username: "anything", + email: email, + first_name: "Auth", + last_name: "Test", + age: 40, + password: "12idontknow", + }; + const res = await request(BASE_URL).post(signupEndpoint).send(signupBody); + + expect(res.status).toBe(409); + expect(res.body.error).toBe("An account with this email already exists."); + }); + + it("failed signup due to invalid email format", async () => { + const signupBody = { + username: "anything", + email: "invalidemail", + first_name: "Auth", + last_name: "Test", + age: 40, + password: "12idontknow", + }; + const res = await request(BASE_URL).post(signupEndpoint).send(signupBody); + + expect(res.status).toBe(400); + expect(res.body.error).toBe("invalid email format"); + }); + + it("failed signup due to invalid password", async () => { + const signupBody = { + username: "anything", + email: "newaccount@example.com", + first_name: "Auth", + last_name: "Test", + age: 40, + password: "short", + }; + const res = await request(BASE_URL).post(signupEndpoint).send(signupBody); + + expect(res.status).toBe(400); + expect(res.body.error).toBe( + "Password must be at least 8 characters and include letters and numbers.", + ); + }); + + it("failed signup due to under age", async () => { + const signupBody = { + username: "anything", + email: "newaccount@example.com", + first_name: "Auth", + last_name: "Test", + age: 15, + password: "longenoughpassword1", + }; + const res = await request(BASE_URL).post(signupEndpoint).send(signupBody); + + expect(res.status).toBe(400); + expect(res.body.error).toBe( + "Age must be a whole number between 16 and 120", + ); + }); + + it("failed signup due to over age", async () => { + const signupBody = { + username: "anything", + email: "newaccount@example.com", + first_name: "Auth", + last_name: "Test", + age: 121, + password: "longenoughpassword1", + }; + const res = await request(BASE_URL).post(signupEndpoint).send(signupBody); + + expect(res.status).toBe(400); + expect(res.body.error).toBe( + "Age must be a whole number between 16 and 120", + ); + }); + + it("logs in an existing user", async () => { + const loginBody = { + email: email, + password: password, + }; + const res = await request(BASE_URL).post(loginEndpoint).send(loginBody); + + expect(res.status).toBe(200); + expect(res.body.token).toBeDefined(); + }); + + it("logs in with wrong password", async () => { + const loginBody = { + email: email, + password: "wrongpassword", + }; + const res = await request(BASE_URL).post(loginEndpoint).send(loginBody); + + expect(res.status).toBe(401); + expect(res.body.error).toBe("Invalid email or password."); + }); + + it("logs into non existing account", async () => { + const loginBody = { + email: "nonexist@exmaple.com", + password: "bskdfks312", + }; + const res = await request(BASE_URL).post(loginEndpoint).send(loginBody); + + expect(res.status).toBe(401); + expect(res.body.error).toBe("Invalid email or password."); + }); + + it("logs in with empty credentials", async () => { + const loginBody = { + email: "", + password: "", + }; + const res = await request(BASE_URL).post(loginEndpoint).send(loginBody); + + expect(res.status).toBe(400); + expect(res.body.error).toBe("Missing email"); + }); +}); From 33218bf9722ec44c80eb55a03319334bdaa54c6f Mon Sep 17 00:00:00 2001 From: Huy Truong Date: Tue, 31 Mar 2026 22:20:02 -0500 Subject: [PATCH 13/23] Server: created setup for integration tests in analytics service --- app/server/docker-compose.test.yml | 9 ++++++ .../analytics/Dockerfile.integration.test | 13 ++++++++ .../python/analytics/integration/conftest.py | 32 +++++++++++++++++++ .../integration/test_debts_integration.py | 0 .../integration/test_savings_integration.py | 15 +++++++++ .../python/analytics/requirements-test.txt | 3 +- 6 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 app/server/services/python/analytics/Dockerfile.integration.test create mode 100644 app/server/services/python/analytics/integration/conftest.py create mode 100644 app/server/services/python/analytics/integration/test_debts_integration.py create mode 100644 app/server/services/python/analytics/integration/test_savings_integration.py diff --git a/app/server/docker-compose.test.yml b/app/server/docker-compose.test.yml index 8c620d9..0578c70 100644 --- a/app/server/docker-compose.test.yml +++ b/app/server/docker-compose.test.yml @@ -59,3 +59,12 @@ services: dockerfile: auth/Dockerfile.integration.test env_file: - ./default_container.env + + analytics-integration-test: + build: + context: ./services/python/analytics + dockerfile: Dockerfile.integration.test + env_file: + - ./default_container.env + environment: + - JWT_SECRET diff --git a/app/server/services/python/analytics/Dockerfile.integration.test b/app/server/services/python/analytics/Dockerfile.integration.test new file mode 100644 index 0000000..8b1f0e8 --- /dev/null +++ b/app/server/services/python/analytics/Dockerfile.integration.test @@ -0,0 +1,13 @@ +FROM measureonecodetwice/finus-analytics:local + +WORKDIR /app + +COPY integration ./integration +COPY requirements.txt . +COPY requirements-test.txt . + +RUN pip install -r requirements-test.txt + +ENV CI=true + +CMD ["python", "-m", "pytest", "integration/", "-v"] diff --git a/app/server/services/python/analytics/integration/conftest.py b/app/server/services/python/analytics/integration/conftest.py new file mode 100644 index 0000000..49686dc --- /dev/null +++ b/app/server/services/python/analytics/integration/conftest.py @@ -0,0 +1,32 @@ +import os +import time +import httpx +import pytest + +ANALYTICS_SERVICE_ADDR = os.getenv("ANALYTICS_SERVICE_ADDR", "http://analytics:8000") +READY_TIMEOUT_SECONDS = float(os.getenv("ANALYTICS_READY_TIMEOUT_SECONDS", "60")) + +def wait_for_analytics_service(base_url: str, timeout_seconds: float) -> None: + deadline = time.monotonic() + timeout_seconds + last_error = "analytics service never became reachable" + + while time.monotonic() < deadline: + try: + response = httpx.get(f"{base_url}/health", timeout=5.0) + 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: + last_error = str(exc) + + time.sleep(1) + + pytest.fail(f"Market service was not ready within {timeout_seconds}s: {last_error}") + + +@pytest.fixture(scope="session") +def analytics_client() -> httpx.Client: + wait_for_analytics_service(ANALYTICS_SERVICE_ADDR, READY_TIMEOUT_SECONDS) + + with httpx.Client(base_url=ANALYTICS_SERVICE_ADDR, timeout=15.0) as client: + yield client \ No newline at end of file diff --git a/app/server/services/python/analytics/integration/test_debts_integration.py b/app/server/services/python/analytics/integration/test_debts_integration.py new file mode 100644 index 0000000..e69de29 diff --git a/app/server/services/python/analytics/integration/test_savings_integration.py b/app/server/services/python/analytics/integration/test_savings_integration.py new file mode 100644 index 0000000..d8357d0 --- /dev/null +++ b/app/server/services/python/analytics/integration/test_savings_integration.py @@ -0,0 +1,15 @@ +import os +import time + +import httpx +import pytest + + +def test_analytics_health_endpoint(analytics_client: httpx.Client) -> None: + response = analytics_client.get("/health") + + assert response.status_code == 200 + assert response.text == "ok" + + print("Analytics service is healthy.") + diff --git a/app/server/services/python/analytics/requirements-test.txt b/app/server/services/python/analytics/requirements-test.txt index 2236104..1793c7d 100644 --- a/app/server/services/python/analytics/requirements-test.txt +++ b/app/server/services/python/analytics/requirements-test.txt @@ -7,4 +7,5 @@ pyjwt==2.11.0 pytest>=7.0.0 pytest-asyncio>=0.21.0 pytest-cov>=4.1.0 -pytest-mock>=3.11.0 \ No newline at end of file +pytest-mock>=3.11.0 +httpx>=0.27.0,<0.28.0 \ No newline at end of file From fb7eaddad3e13bd7f2ea3816ea8b36cc2d60451d Mon Sep 17 00:00:00 2001 From: Huy Truong Date: Wed, 1 Apr 2026 15:19:26 -0500 Subject: [PATCH 14/23] Sever: Added integration test for compound interest endpoint --- .../python/analytics/integration/conftest.py | 168 +++++++++++++++++- .../integration/test_savings_integration.py | 50 ++++++ .../python/analytics/src/logic/savings.py | 30 ---- .../services/python/analytics/src/main.py | 2 +- 4 files changed, 215 insertions(+), 35 deletions(-) diff --git a/app/server/services/python/analytics/integration/conftest.py b/app/server/services/python/analytics/integration/conftest.py index 49686dc..6a83ef3 100644 --- a/app/server/services/python/analytics/integration/conftest.py +++ b/app/server/services/python/analytics/integration/conftest.py @@ -1,14 +1,21 @@ +from typing import Dict import os import time +import random +import string +import re import httpx import pytest +from datetime import datetime, timedelta ANALYTICS_SERVICE_ADDR = os.getenv("ANALYTICS_SERVICE_ADDR", "http://analytics:8000") +USER_SERVICE_ADDR = os.getenv("USER_SERVICE_ADDR", "http://user:8000") +AUTH_SERVICE_ADDR = os.getenv("AUTH_SERVICE_ADDR", "http://auth:8000") READY_TIMEOUT_SECONDS = float(os.getenv("ANALYTICS_READY_TIMEOUT_SECONDS", "60")) -def wait_for_analytics_service(base_url: str, timeout_seconds: float) -> None: +def wait_for_service(base_url: str, timeout_seconds: float) -> None: deadline = time.monotonic() + timeout_seconds - last_error = "analytics service never became reachable" + last_error = "service never became reachable" while time.monotonic() < deadline: try: @@ -23,10 +30,163 @@ def wait_for_analytics_service(base_url: str, timeout_seconds: float) -> None: pytest.fail(f"Market service was not ready within {timeout_seconds}s: {last_error}") +@pytest.fixture(scope="session") +def auth_client() -> httpx.Client: + wait_for_service(AUTH_SERVICE_ADDR, READY_TIMEOUT_SECONDS) + with httpx.Client(base_url=AUTH_SERVICE_ADDR, timeout=15.0) as client: + yield client + +@pytest.fixture(scope="session") +def user_client() -> httpx.Client: + wait_for_service(USER_SERVICE_ADDR, READY_TIMEOUT_SECONDS) + with httpx.Client(base_url=USER_SERVICE_ADDR, timeout=15.0) as client: + yield client @pytest.fixture(scope="session") def analytics_client() -> httpx.Client: - wait_for_analytics_service(ANALYTICS_SERVICE_ADDR, READY_TIMEOUT_SECONDS) + wait_for_service(ANALYTICS_SERVICE_ADDR, READY_TIMEOUT_SECONDS) with httpx.Client(base_url=ANALYTICS_SERVICE_ADDR, timeout=15.0) as client: - yield client \ No newline at end of file + yield client + +def sleep(ms: int): + time.sleep(ms / 1000) + + +def create_signup_body(prefix: str) -> dict: + safe_prefix = re.sub(r"[^\w]", "", prefix) + unique = f"{safe_prefix}{int(time.time() * 1000)}{''.join(random.choices(string.ascii_lowercase + string.digits, k=6))}" + + return { + "username": unique, + "email": f"{unique}@sample.com", + "first_name": "Test", + "last_name": "User", + "age": 30, + "password": "123ABC!7", + } + + +def create_authenticated_account( + auth_client: httpx.Client, + user_client: httpx.Client, + prefix: str, + account_overrides: dict = None, +): + deadline = time.time() + 20 + last_failure = "setup did not start" + + account_overrides = account_overrides or {} + + while time.time() < deadline: + signup_body = create_signup_body(prefix) + + try: + # Signup + signup = auth_client.post("/signup", json=signup_body) + if signup.status_code != 201: + last_failure = f"signup={signup.status_code} {signup.text}" + sleep(500) + continue + + # Login + login = auth_client.post( + "/login", + json={ + "email": signup_body["email"], + "password": signup_body["password"], + }, + ) + + login_json = login.json() if login.content else {} + + if login.status_code != 200 or "token" not in login_json: + last_failure = f"login={login.status_code} {login.text}" + sleep(500) + continue + + token = login_json["token"] + + # Create account + account_payload = { + "name": account_overrides.get("name", "Sample Account"), + "type": account_overrides.get("type", "savings"), + "balance": account_overrides.get("balance", 1000), + "value": account_overrides.get("value", 1000), + "subtype": account_overrides.get("subtype", "RRSP"), + } + + account = user_client.post( + "/accounts", + json=account_payload, + headers={"Authorization": f"Bearer {token}"}, + ) + + account_json = account.json() if account.content else {} + + print("Account creation response:", account.status_code, account_json) + + if account.status_code != 200 or "id" not in account_json: + last_failure = f"account={account.status_code} {account.text}" + sleep(500) + continue + + account_id = account_json["id"] + + return { + "token": token, + "account_id": account_id, + } + + except httpx.HTTPError as error: + last_failure = str(error) + print("Setup error:", last_failure) + sleep(500) + + raise RuntimeError(f"Timed out creating authenticated account: {last_failure}") + +def create_transaction(user_client, account_id, token): + transactions = [] + + base_date = datetime(2024, 1, 1) + + for _ in range(12): + # Random amount between 1 and 10000 + amount = round(random.uniform(1, 10000), 2) + + # Random date within 720 days + random_days = random.randint(0, 720) + random_seconds = random.randint(0, 86400) + + transaction_date = base_date + timedelta(days=random_days, seconds=random_seconds) + + transaction_payload = { + "financialAccount_id": account_id, + "amount": amount, + "description": "Sample transaction", + "sender": "Unknown", + "recipient": "Tester", + "date": transaction_date.isoformat(), + "category": "None", + } + + response = user_client.post( + "/transactions", + json=transaction_payload, + headers={"Authorization": f"Bearer {token}"} + ) + + print("Transaction:", response.status_code, response.text) + transactions.append(response.json() if response.content else {}) + +@pytest.fixture(scope="session") +def auth_account(auth_client, user_client) -> Dict: + account = create_authenticated_account( + auth_client=auth_client, + user_client=user_client, + prefix="testuser", + ) + print("Authenticated account created:", account) + create_transaction(user_client, account["account_id"], account["token"]) + return account + diff --git a/app/server/services/python/analytics/integration/test_savings_integration.py b/app/server/services/python/analytics/integration/test_savings_integration.py index d8357d0..eb9a09f 100644 --- a/app/server/services/python/analytics/integration/test_savings_integration.py +++ b/app/server/services/python/analytics/integration/test_savings_integration.py @@ -1,5 +1,6 @@ import os import time +from typing import Dict import httpx import pytest @@ -13,3 +14,52 @@ def test_analytics_health_endpoint(analytics_client: httpx.Client) -> None: print("Analytics service is healthy.") +def test_compound_interest(analytics_client: httpx.Client, auth_account: Dict) -> None: + token = auth_account["token"] + account_id = auth_account["account_id"] + + payload = { + "financial_account_id": account_id, + "balance": 1000, + "monthly_deposit": 100, + "annual_interest_rate": 5.0, + "time_frame": 2, + } + + response = analytics_client.post( + "/compound-interest", + json=payload, + headers={"Authorization": f"Bearer {token}"}, + ) + + assert response.status_code == 200, response.text + + data = response.json() + + assert isinstance(data, list) + assert len(data) > 0 + + for entry in data: + assert "accumulative_best_balance" in entry + assert "accumulative_expected_balance" in entry + assert "accumulative_worst_balance" in entry + assert "date" in entry + + assert isinstance(entry["accumulative_best_balance"], (int, float)) + assert isinstance(entry["accumulative_expected_balance"], (int, float)) + assert isinstance(entry["accumulative_worst_balance"], (int, float)) + assert isinstance(entry["date"], str) + + for entry in data: + best = entry["accumulative_best_balance"] + expected = entry["accumulative_expected_balance"] + worst = entry["accumulative_worst_balance"] + + assert best >= expected >= worst >= 0, "Balances should be non-negative and ordered correctly" + + + first = data[0]["accumulative_expected_balance"] + last = data[-1]["accumulative_expected_balance"] + + assert last >= first, "Balance should grow over time" + diff --git a/app/server/services/python/analytics/src/logic/savings.py b/app/server/services/python/analytics/src/logic/savings.py index 1e9da07..66bb383 100644 --- a/app/server/services/python/analytics/src/logic/savings.py +++ b/app/server/services/python/analytics/src/logic/savings.py @@ -58,36 +58,6 @@ def calculate_savings_over_time( }] } -# def get_monthly_balances(transactions: pd.DataFrame): -# df = transactions.copy() -# df['date'] = pd.to_datetime(df['date']) - -# # Sort by date -# df = df.sort_values('date') - -# # Group by month -# df['year_month'] = df['date'].dt.to_period('M') - -# monthly_sums = df.groupby('year_month')['amount'].sum().reset_index() - -# monthly_sums['year_month'] = monthly_sums['year_month'].dt.to_timestamp() - -# # Create full monthly range -# full_range = pd.date_range( -# start=monthly_sums['year_month'].min(), -# end=monthly_sums['year_month'].max(), -# freq='MS' # month start -# ) - -# monthly_sums = monthly_sums.set_index('year_month').reindex(full_range, fill_value=0) -# monthly_sums = monthly_sums.rename_axis('date').reset_index() - -# # Convert to cumulative balance -# monthly_sums['balance'] = monthly_sums['amount'].cumsum() -# print(monthly_sums) - -# return monthly_sums - def get_monthly_balances(transactions: pd.DataFrame): df = transactions.copy() df['date'] = pd.to_datetime(df['date']) diff --git a/app/server/services/python/analytics/src/main.py b/app/server/services/python/analytics/src/main.py index c0ada55..ce6c751 100644 --- a/app/server/services/python/analytics/src/main.py +++ b/app/server/services/python/analytics/src/main.py @@ -141,7 +141,7 @@ async def predict_debt_payoff( @app.post('/compound-interest') -async def compount_interest( +async def compound_interest( requestBody: ProjectedSavingsRequest, user_id: int = Depends(get_current_user), ) -> List[CompoundInterestResponse]: From 5db47c5a118dd742a11fa0fd9a1b31966dd9247b Mon Sep 17 00:00:00 2001 From: Huy Truong Date: Thu, 2 Apr 2026 21:14:33 -0500 Subject: [PATCH 15/23] Server: more integration tests --- .../integration/test_debts_integration.py | 137 ++++++++++++++++++ .../integration/test_savings_integration.py | 57 +++++++- .../python/analytics/src/logic/debt.py | 2 +- .../python/analytics/src/models/schemas.py | 14 +- 4 files changed, 197 insertions(+), 13 deletions(-) diff --git a/app/server/services/python/analytics/integration/test_debts_integration.py b/app/server/services/python/analytics/integration/test_debts_integration.py index e69de29..c253fbb 100644 --- a/app/server/services/python/analytics/integration/test_debts_integration.py +++ b/app/server/services/python/analytics/integration/test_debts_integration.py @@ -0,0 +1,137 @@ +from typing import Dict + +import httpx +import pytest + +def test_predict_debt_payoff(auth_account: httpx.Client, analytics_client: httpx.Client): + token = auth_account["token"] + account_id = auth_account["account_id"] + + payload = { + "id": str(account_id), + "category": "Credit Card", + "remainingAmount": 2000, + "interestRate": 20, + "minimumPayment": 100, + "period": 30, # days between payments + "nextDueDate": "2024-07-01" + } + + response = analytics_client.post( + "/predict-debt-payoff", + json=payload, + headers={"Authorization": f"Bearer {token}"}, + ) + + assert response.status_code == 200, response.text + + data = response.json() + + assert isinstance(data, dict) + + assert "id" in data + assert "category" in data + assert "minimumPayment" in data + assert "interestRate" in data + assert "debtStages" in data + + assert isinstance(data["debtStages"], list) + assert len(data["debtStages"]) > 0 + + debts = data["debtStages"] + + for debt in debts: + assert "id" in debt + assert "principalAmount" in debt + assert "interestAmount" in debt + assert "remainingDebt" in debt + assert "installmentDate" in debt + +def test_predict_debt_payoff_with_unreasonable_payment(auth_account: httpx.Client, analytics_client: httpx.Client): + token = auth_account["token"] + account_id = auth_account["account_id"] + + # Test with a minimum payment that is too low to ever pay off the debt + payload = { + "id": str(account_id), + "category": "Credit Card", + "remainingAmount": 400000, + "minimumPayment": 100, + "interestRate": 20.0, + "period": 14, # days between payments + "nextDueDate": "2024-07-01" + } + + response = analytics_client.post( + "/predict-debt-payoff", + json=payload, + headers={"Authorization": f"Bearer {token}"}, + ) + + assert response.status_code == 400 + + data = response.json() + print(f"Data: {data}") + + assert isinstance(data, dict) + assert "detail" in data + assert "Minimum payment is too low. Debt will never be paid off." in data["detail"] + +def test_predict_debt_payoff_with_negative_minimum_payment(analytics_client: httpx.Client, auth_account: Dict): + token = auth_account["token"] + account_id = auth_account["account_id"] + + # Test with negative minimum payment + payload = { + "id": str(account_id), + "category": "Credit Card", + "remainingAmount": 400000, + "interestRate": 20.0, + "minimumPayment": -100, + "period": 14, # days between payments + "nextDueDate": "2024-07-01" + } + + response = analytics_client.post( + "/predict-debt-payoff", + json=payload, + headers={"Authorization": f"Bearer {token}"}, + ) + + assert response.status_code == 422, response.text + + data = response.json() + + assert "detail" in data + assert isinstance(data["detail"], list) + assert any("minimumPayment" in str(error) for error in data["detail"]), "Error should mention minimumPayment" + +def test_predict_debt_payoff_with_negative_period(analytics_client: httpx.Client, auth_account: Dict): + token = auth_account["token"] + account_id = auth_account["account_id"] + + # Test with negative period + payload = { + "id": str(account_id), + "category": "Credit Card", + "remainingAmount": 400000, + "interestRate": 20.0, + "minimumPayment": -100, + "period": -14, # days between payments + "nextDueDate": "2024-07-01" + } + + response = analytics_client.post( + "/predict-debt-payoff", + json=payload, + headers={"Authorization": f"Bearer {token}"}, + ) + + assert response.status_code == 422, response.text + + data = response.json() + + assert "detail" in data + assert isinstance(data["detail"], list) + assert any("period" in str(error) for error in data["detail"]), "Error should mention period" + diff --git a/app/server/services/python/analytics/integration/test_savings_integration.py b/app/server/services/python/analytics/integration/test_savings_integration.py index eb9a09f..0f40b5e 100644 --- a/app/server/services/python/analytics/integration/test_savings_integration.py +++ b/app/server/services/python/analytics/integration/test_savings_integration.py @@ -45,11 +45,6 @@ def test_compound_interest(analytics_client: httpx.Client, auth_account: Dict) - assert "accumulative_worst_balance" in entry assert "date" in entry - assert isinstance(entry["accumulative_best_balance"], (int, float)) - assert isinstance(entry["accumulative_expected_balance"], (int, float)) - assert isinstance(entry["accumulative_worst_balance"], (int, float)) - assert isinstance(entry["date"], str) - for entry in data: best = entry["accumulative_best_balance"] expected = entry["accumulative_expected_balance"] @@ -63,3 +58,55 @@ def test_compound_interest(analytics_client: httpx.Client, auth_account: Dict) - assert last >= first, "Balance should grow over time" +def test_compound_interest_with_negative_monthly_deposit(analytics_client: httpx.Client, auth_account: Dict) -> None: + token = auth_account["token"] + account_id = auth_account["account_id"] + + # Test with negative monthly deposit + payload = { + "financial_account_id": account_id, + "balance": 1000, + "monthly_deposit": -100, + "annual_interest_rate": 5.0, + "time_frame": 2, + } + + response = analytics_client.post( + "/compound-interest", + json=payload, + headers={"Authorization": f"Bearer {token}"}, + ) + + assert response.status_code == 422, response.text + + data = response.json() + assert "detail" in data + assert isinstance(data["detail"], list) + assert any("monthly_deposit" in str(error) for error in data["detail"]), "Error should mention monthly_deposit" + +def test_compound_interest_with_negative_time_frame(analytics_client: httpx.Client, auth_account: Dict) -> None: + token = auth_account["token"] + account_id = auth_account["account_id"] + + # Test with negative time frame + payload = { + "financial_account_id": account_id, + "balance": 1000, + "monthly_deposit": 100, + "annual_interest_rate": 5.0, + "time_frame": -2, + } + + response = analytics_client.post( + "/compound-interest", + json=payload, + headers={"Authorization": f"Bearer {token}"}, + ) + + assert response.status_code == 422, response.text + + data = response.json() + assert "detail" in data + assert isinstance(data["detail"], list) + assert any("time_frame" in str(error) for error in data["detail"]), "Error should mention time_frame" + diff --git a/app/server/services/python/analytics/src/logic/debt.py b/app/server/services/python/analytics/src/logic/debt.py index cf763ec..4dafcdf 100644 --- a/app/server/services/python/analytics/src/logic/debt.py +++ b/app/server/services/python/analytics/src/logic/debt.py @@ -9,7 +9,7 @@ def generate_debt_payoff_stages(dbr: DebtPayoffRequest) -> DebtPayoffResponse: minimum_payment = dbr.minimumPayment interest_rate = dbr.interestRate or 0 - monthly_interest_rate = round(interest_rate / 12 / 100, 5) if interest_rate else 0 + monthly_interest_rate = round(interest_rate / 12 / 100, 4) if interest_rate else 0 expected_date = datetime.strptime(dbr.nextDueDate, "%Y-%m-%d") stages: List[DebtPayoffStage] = [] diff --git a/app/server/services/python/analytics/src/models/schemas.py b/app/server/services/python/analytics/src/models/schemas.py index be89b8f..26323d6 100644 --- a/app/server/services/python/analytics/src/models/schemas.py +++ b/app/server/services/python/analytics/src/models/schemas.py @@ -1,5 +1,5 @@ from numpy import number -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import List, Literal, Optional from datetime import datetime @@ -58,10 +58,10 @@ class DebtPayoffRequest(BaseModel): id: str category: str remainingAmount: float - minimumPayment: float - interestRate: Optional[float] = 0 + minimumPayment: float = Field(..., gt=0, description="Minimum payment must be greater than 0") + interestRate: Optional[float] = Field(default=0, description="Interest rate in percentage") nextDueDate: str # YYYY-MM-DD - period: int # days + period: int = Field(..., gt=0, description="Period must be greater than 0") # in days class DebtPayoffStage(BaseModel): id: int @@ -80,9 +80,9 @@ class DebtPayoffResponse(BaseModel): class ProjectedSavingsRequest(BaseModel): financial_account_id: int balance: float - monthly_deposit: float - annual_interest_rate: float | None # annual interest rate in percentage - time_frame: int # in years + monthly_deposit: float = Field(..., gt=0, description="Monthly deposit must be greater than 0") + annual_interest_rate: Optional[float] = None # annual interest rate in percentage + time_frame: int = Field(..., gt=0, description="Time frame must be greater than 0") # in years class MonthlySavingGrowthRate(BaseModel): best_case: float expected_case: float From cca0d4c8161a10a3732d4ec44075d40e51e0fc98 Mon Sep 17 00:00:00 2001 From: Logan Date: Fri, 3 Apr 2026 11:26:21 -0500 Subject: [PATCH 16/23] address sonarqube issues --- app/server/run_tests.sh | 61 ++++++++++++++---------- app/server/services/ts/common/sqlUtil.ts | 2 +- 2 files changed, 38 insertions(+), 25 deletions(-) diff --git a/app/server/run_tests.sh b/app/server/run_tests.sh index ea4ddcc..f64db98 100755 --- a/app/server/run_tests.sh +++ b/app/server/run_tests.sh @@ -7,18 +7,45 @@ quit() { cleanup exit 0 fi + return } + cleanup() { echo "Cleaning up..." docker compose -f docker-compose.test.yml -f docker-compose.yml down + return +} + +waitforserviceshealthy() { + source .env + + is_app_healthy() { + echo $(curl localhost:$API_GATEWAY_PORT/health 2> /dev/null | grep -Pc '^{(\"[^\"]*\":\"ok\",?)*}$') + return + } + + 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" + return 1 + fi + return 0 } + + trap quit SIGINT -#--- Build images --- echo "Building app images..." docker compose build -q status=$? -if [ $status -ne 0 ]; then +if [[ $status -ne 0 ]]; then echo "Failed to build services" exit $status fi @@ -26,39 +53,25 @@ fi echo "Building test images..." docker compose -f docker-compose.test.yml build -q status=$? -if [ $status -ne 0 ]; then +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" +healthy=$(waitforserviceshealthy) +if [[ ! healthy ]]; then + echo "Max retries exceeded. Exiting" exit 1 +else + echo "Services healthy" fi -#--- Run tests --- + docker compose -f docker-compose.test.yml up --abort-on-container-failure test_status=$? -if [ $test_status -eq 0 ]; then +if [[ $test_status -eq 0 ]]; then echo '+--------------------+' echo '| ✅ All tests pass! |' echo '+--------------------+' diff --git a/app/server/services/ts/common/sqlUtil.ts b/app/server/services/ts/common/sqlUtil.ts index 1b6ae23..c4b9269 100644 --- a/app/server/services/ts/common/sqlUtil.ts +++ b/app/server/services/ts/common/sqlUtil.ts @@ -16,7 +16,7 @@ export const getConnectionPool = () => }); export type DatabaseStatus = "unhealthy" | "ok"; -export const getDatabaseStatus = async (): DatabaseStatus => { +export const getDatabaseStatus = async (): Promise => { let status: DatabaseStatus; try { From ea055bf241cd08a77f4ffdba8fab9bd6b02dd1fb Mon Sep 17 00:00:00 2001 From: Huy Truong Date: Fri, 3 Apr 2026 11:35:36 -0500 Subject: [PATCH 17/23] Config mutation tests --- app/server/docker-compose.test.yml | 19 +- .../python/analytics/Dockerfile.mutation.test | 21 +++ .../services/python/analytics/pyproject.toml | 8 + .../python/analytics/requirements-test.txt | 4 +- .../python/analytics/requirements.txt | 8 +- .../python/analytics/src/models/__init__.py | 0 .../python/analytics/test/budgetPerfCalc.py | 152 ---------------- .../analytics/test/budget_integration.py | 38 ---- .../python/analytics/test/cat_classifier.py | 55 ------ .../python/analytics/test/conftest.py | 167 ------------------ .../python/analytics/test/incomeflow.py | 131 -------------- .../python/analytics/test/periodCalc.py | 146 --------------- .../services/python/analytics/test/savings.py | 99 ----------- .../services/ts/auth/Dockerfile.mutation.test | 15 ++ app/server/services/ts/auth/package.json | 3 +- app/server/services/ts/auth/stryker.conf.js | 6 + app/server/services/ts/auth/vitest.config.ts | 9 + 17 files changed, 82 insertions(+), 799 deletions(-) create mode 100644 app/server/services/python/analytics/Dockerfile.mutation.test create mode 100644 app/server/services/python/analytics/pyproject.toml create mode 100644 app/server/services/python/analytics/src/models/__init__.py delete mode 100644 app/server/services/python/analytics/test/budgetPerfCalc.py delete mode 100644 app/server/services/python/analytics/test/budget_integration.py delete mode 100644 app/server/services/python/analytics/test/cat_classifier.py delete mode 100644 app/server/services/python/analytics/test/conftest.py delete mode 100644 app/server/services/python/analytics/test/incomeflow.py delete mode 100644 app/server/services/python/analytics/test/periodCalc.py delete mode 100644 app/server/services/python/analytics/test/savings.py create mode 100644 app/server/services/ts/auth/Dockerfile.mutation.test create mode 100644 app/server/services/ts/auth/stryker.conf.js create mode 100644 app/server/services/ts/auth/vitest.config.ts diff --git a/app/server/docker-compose.test.yml b/app/server/docker-compose.test.yml index 0578c70..d8e0e4a 100644 --- a/app/server/docker-compose.test.yml +++ b/app/server/docker-compose.test.yml @@ -22,18 +22,27 @@ services: dockerfile: Dockerfile.test # # ------ MUTATION TESTS ------ - # user-mutation-test: - # build: - # context: ./services/ts - # dockerfile: user/Dockerfile.mutation.test + user-mutation-test: + build: + context: ./services/ts + dockerfile: user/Dockerfile.mutation.test + + auth-mutation-test: + build: + context: ./services/ts + dockerfile: auth/Dockerfile.mutation.test market-mutation-test: build: context: services/python/market dockerfile: Dockerfile.mutation.test + analytics-mutation-test: + build: + context: services/python/analytics + dockerfile: Dockerfile.mutation.test + # MISSING - # ANALYTICS # AUTH # ------ INTEGRATION TESTS ------ diff --git a/app/server/services/python/analytics/Dockerfile.mutation.test b/app/server/services/python/analytics/Dockerfile.mutation.test new file mode 100644 index 0000000..9d7a14d --- /dev/null +++ b/app/server/services/python/analytics/Dockerfile.mutation.test @@ -0,0 +1,21 @@ +FROM measureonecodetwice/finus-analytics:local + +WORKDIR /app + +COPY src ./src +COPY test ./test +COPY requirements.txt . +COPY requirements-test.txt . +COPY pyproject.toml . + +RUN pip install -r requirements-test.txt + +RUN cp -r src analytics_src +RUN cp -r test mutation_test +RUN find mutation_test -type f -name "*.py" -exec perl -0pi -e "s/\\bfrom src import\\b/from analytics_src import/g; s/\\bimport src\\./import analytics_src./g; s/\\bsrc\\./analytics_src./g" {} + +RUN rm -rf src test + +ENV PYTHONPATH=/app +ENV CI=true + +CMD ["mutmut", "run"] diff --git a/app/server/services/python/analytics/pyproject.toml b/app/server/services/python/analytics/pyproject.toml new file mode 100644 index 0000000..688e46f --- /dev/null +++ b/app/server/services/python/analytics/pyproject.toml @@ -0,0 +1,8 @@ +[tool.mutmut] +paths_to_mutate = ["analytics_src/"] +pytest_add_cli_args_test_selection = ["mutation_test/"] +pytest_add_cli_args = ["-q"] +mutate_only_covered_lines = true +do_not_mutate = ["analytics_src/__init__.py"] +also_copy = ["mutation_test/"] +debug = true diff --git a/app/server/services/python/analytics/requirements-test.txt b/app/server/services/python/analytics/requirements-test.txt index 1793c7d..04b8062 100644 --- a/app/server/services/python/analytics/requirements-test.txt +++ b/app/server/services/python/analytics/requirements-test.txt @@ -1,3 +1,4 @@ +-r requirements.txt pydantic==2.5.0 pandas==2.1.4 numpy==1.26.3 @@ -8,4 +9,5 @@ pytest>=7.0.0 pytest-asyncio>=0.21.0 pytest-cov>=4.1.0 pytest-mock>=3.11.0 -httpx>=0.27.0,<0.28.0 \ No newline at end of file +httpx>=0.27.0,<0.28.0 +mutmut>=3.2.3 \ No newline at end of file diff --git a/app/server/services/python/analytics/requirements.txt b/app/server/services/python/analytics/requirements.txt index 8b65c9c..1a15f0f 100644 --- a/app/server/services/python/analytics/requirements.txt +++ b/app/server/services/python/analytics/requirements.txt @@ -6,7 +6,7 @@ numpy==1.26.3 mysql-connector-python==8.0.33 dotenv==0.9.9 pyjwt==2.11.0 -pytest>=7.0.0 -pytest-asyncio>=0.21.0 -pytest-cov>=4.1.0 -pytest-mock>=3.11.0 \ No newline at end of file +# pytest>=7.0.0 +# pytest-asyncio>=0.21.0 +# pytest-cov>=4.1.0 +# pytest-mock>=3.11.0 \ No newline at end of file diff --git a/app/server/services/python/analytics/src/models/__init__.py b/app/server/services/python/analytics/src/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/server/services/python/analytics/test/budgetPerfCalc.py b/app/server/services/python/analytics/test/budgetPerfCalc.py deleted file mode 100644 index dfa3e17..0000000 --- a/app/server/services/python/analytics/test/budgetPerfCalc.py +++ /dev/null @@ -1,152 +0,0 @@ -import pytest -import pandas as pd -from datetime import datetime -from unittest.mock import patch, ANY -from src.logic.budgetPerfCalc import BudgetPerformanceCalculator -from src.models.schemas import BudgetResponse, BudgetCategory - -pytestmark = pytest.mark.asyncio - -class TestBudgetPerformanceCalculator: - - @pytest.fixture - def performance_calc(self): - return BudgetPerformanceCalculator() - - @pytest.fixture - def mock_transactions(self): - return [ - {'category': 'groceries', 'amount': -300, 'date': '2024-03-10'}, - {'category': 'groceries', 'amount': -150, 'date': '2024-03-12'}, - {'category': 'dining', 'amount': -200, 'date': '2024-03-14'}, - {'category': 'entertainment', 'amount': -100, 'date': '2024-03-15'}, - ] - - @pytest.fixture - def sample_budget_response(self): - categories = [ - BudgetCategory( - category='groceries', - type='need', - avg_monthly_spent=300, - monthly_budget=280, - recommended_budget=65, - is_essential=True - ), - BudgetCategory( - category='dining', - type='want', - avg_monthly_spent=200, - monthly_budget=150, - recommended_budget=35, - is_essential=False - ), - BudgetCategory( - category='entertainment', - type='want', - avg_monthly_spent=150, - monthly_budget=120, - recommended_budget=28, - is_essential=False - ), - ] - return BudgetResponse( - budget=categories, - generated_date=datetime.now().strftime('%Y-%m-%d %H:%M:%S') - ) - - # Synchronous tests - def test_get_period_boundaries_weekly(self, performance_calc): - ref_date = pd.Timestamp('2024-03-15') - start, end = performance_calc.get_period_boundaries('w', ref_date) - assert start.strftime('%Y-%m-%d') == '2024-03-10' - assert end.strftime('%Y-%m-%d') == '2024-03-16' - - def test_get_period_boundaries_monthly(self, performance_calc): - ref_date = pd.Timestamp('2024-03-15') - start, end = performance_calc.get_period_boundaries('m', ref_date) - assert start.strftime('%Y-%m-%d') == '2024-03-01' - assert end.strftime('%Y-%m-%d') == '2024-03-31' - - def test_get_period_boundaries_yearly(self, performance_calc): - ref_date = pd.Timestamp('2024-03-15') - start, end = performance_calc.get_period_boundaries('y', ref_date) - assert start.strftime('%Y-%m-%d') == '2024-01-01' - assert end.strftime('%Y-%m-%d') == '2024-12-31' - - def test_calculate_actual_spending(self, performance_calc, mock_transactions): - result = performance_calc.calculate_actual_spending(mock_transactions) - assert result['groceries'] == 450 - assert result['dining'] == 200 - assert result['entertainment'] == 100 - - # Async tests with proper mocking - async def test_generate_performance( - self, - performance_calc, - sample_budget_response, - mock_transactions - ): - user_id = 123 - period = 'w' - - # Mock the database function at the EXACT import path used in budgetPerfCalc - with patch('src.logic.budgetPerfCalc.get_user_transactions_with_connection') as mock_get: - # Configure the mock to return our test data - mock_get.return_value = mock_transactions - - # Call the async function - result = await performance_calc.generate_performance( - user_id, - sample_budget_response, - period - ) - - # Assertions - assert 'categories' in result - assert 'budgetAmounts' in result - assert 'actualAmounts' in result - assert len(result['categories']) == len(sample_budget_response.budget) - - # Verify the mock was called with correct arguments - mock_get.assert_called_once_with(user_id, ANY, ANY) - - async def test_generate_performance_empty_transactions( - self, - performance_calc, - sample_budget_response - ): - user_id = 123 - period = 'w' - - with patch('src.logic.budgetPerfCalc.get_user_transactions_with_connection') as mock_get: - mock_get.return_value = [] - - result = await performance_calc.generate_performance( - user_id, - sample_budget_response, - period - ) - - # All actual amounts should be 0 - assert all(amount == 0 for amount in result['actualAmounts']) - mock_get.assert_called_once() - - async def test_generate_performance_db_error( - self, - performance_calc, - sample_budget_response - ): - user_id = 123 - period = 'w' - - with patch('src.logic.budgetPerfCalc.get_user_transactions_with_connection') as mock_get: - mock_get.side_effect = Exception("Database connection failed") - - # The error should propagate - with pytest.raises(Exception, match="Database connection failed"): - await performance_calc.generate_performance( - user_id, - sample_budget_response, - period - ) \ No newline at end of file diff --git a/app/server/services/python/analytics/test/budget_integration.py b/app/server/services/python/analytics/test/budget_integration.py deleted file mode 100644 index 56724c3..0000000 --- a/app/server/services/python/analytics/test/budget_integration.py +++ /dev/null @@ -1,38 +0,0 @@ -import pytest -from unittest.mock import patch, AsyncMock -from src.logic.budget import generate_budget, generate_budget_performance -from src.models.schemas import BudgetResponse - -class TestBudgetIntegration: - - @pytest.mark.asyncio - async def test_full_budget_flow(self): - user_id = 123 - period = 'w' - - # Mock transactions for generate_budget - mock_transactions = [ - {'amount': 5000, 'category': 'salary', 'date': '2024-01-01'}, - {'amount': -300, 'category': 'groceries', 'date': '2024-01-02'}, - {'amount': -200, 'category': 'dining', 'date': '2024-01-03'}, - {'amount': 5000, 'category': 'salary', 'date': '2024-02-01'}, - {'amount': -250, 'category': 'groceries', 'date': '2024-02-02'}, - ] - - with patch( - 'src.logic.budget.get_user_transactions_with_connection', - return_value=mock_transactions - ): - # Generate budget - budget = generate_budget(period, user_id) - - assert isinstance(budget, BudgetResponse) - assert len(budget.budget) > 0 - - # Generate performance - performance = await generate_budget_performance(user_id, budget, period) - - assert 'categories' in performance - assert 'budgetAmounts' in performance - assert 'actualAmounts' in performance - assert len(performance['categories']) == len(budget.budget) \ No newline at end of file diff --git a/app/server/services/python/analytics/test/cat_classifier.py b/app/server/services/python/analytics/test/cat_classifier.py deleted file mode 100644 index ef7cb27..0000000 --- a/app/server/services/python/analytics/test/cat_classifier.py +++ /dev/null @@ -1,55 +0,0 @@ -import pytest -from src.utils.trans_cat_classifier import CategoryClassifier - -class TestCategoryClassifier: - - def test_classify_needs(self, classifier): - assert classifier.classify('rent') == 'need' - assert classifier.classify('groceries') == 'need' - assert classifier.classify('healthcare') == 'need' - assert classifier.classify('utilities') == 'need' - - def test_classify_wants(self, classifier): - assert classifier.classify('dining') == 'want' - assert classifier.classify('entertainment') == 'want' - assert classifier.classify('shopping') == 'want' - assert classifier.classify('coffee') == 'want' - - def test_classify_savings(self, classifier): - assert classifier.classify('savings') == 'savings' - assert classifier.classify('investment') == 'savings' - assert classifier.classify('retirement') == 'savings' - - def test_classify_unknown_defaults_to_want(self, classifier): - assert classifier.classify('unknown_category') == 'want' - assert classifier.classify('random_stuff') == 'want' - - def test_classify_case_insensitive(self, classifier): - assert classifier.classify('RENT') == 'need' - assert classifier.classify('Groceries') == 'need' - assert classifier.classify('DINING') == 'want' - - def test_classify_many(self, classifier): - categories = {'rent', 'dining', 'savings', 'unknown'} - result = classifier.classify_many(categories) - - assert result['rent'] == 'need' - assert result['dining'] == 'want' - assert result['savings'] == 'savings' - assert result['unknown'] == 'want' - - def test_get_categories_by_type(self, classifier): - category_types = { - 'rent': 'need', - 'groceries': 'need', - 'dining': 'want', - 'savings': 'savings' - } - - needs = classifier.get_categories_by_type(category_types, 'need') - wants = classifier.get_categories_by_type(category_types, 'want') - savings = classifier.get_categories_by_type(category_types, 'savings') - - assert needs == {'rent', 'groceries'} - assert wants == {'dining'} - assert savings == {'savings'} \ No newline at end of file diff --git a/app/server/services/python/analytics/test/conftest.py b/app/server/services/python/analytics/test/conftest.py deleted file mode 100644 index 1dfc278..0000000 --- a/app/server/services/python/analytics/test/conftest.py +++ /dev/null @@ -1,167 +0,0 @@ -import pytest -import pandas as pd -from unittest.mock import patch, MagicMock -from datetime import datetime, timedelta -from src.models.schemas import BudgetCategory, BudgetResponse, DebtPayoffRequest -from src.utils.trans_cat_classifier import CategoryClassifier -import jwt -import os - -@pytest.fixture -def sample_transactions(): - base_date = datetime.now() - timedelta(days=30) - return [ - {'amount': 5000, 'category': 'salary', 'date': base_date}, - {'amount': 200, 'category': 'groceries', 'date': base_date + timedelta(days=2)}, - {'amount': -50, 'category': 'dining', 'date': base_date + timedelta(days=3)}, - {'amount': -100, 'category': 'groceries', 'date': base_date + timedelta(days=5)}, - {'amount': -30, 'category': 'coffee', 'date': base_date + timedelta(days=7)}, - {'amount': -200, 'category': 'entertainment', 'date': base_date + timedelta(days=10)}, - {'amount': 5000, 'category': 'salary', 'date': base_date + timedelta(days=30)}, - ] - -@pytest.fixture -def sample_budget_response(): - categories = [ - BudgetCategory( - category='groceries', - type='need', - avg_monthly_spent=300, - monthly_budget=280, - recommended_budget=65, # weekly - is_essential=True - ), - BudgetCategory( - category='dining', - type='want', - avg_monthly_spent=200, - monthly_budget=150, - recommended_budget=35, # weekly - is_essential=False - ), - BudgetCategory( - category='entertainment', - type='want', - avg_monthly_spent=150, - monthly_budget=120, - recommended_budget=28, # weekly - is_essential=False - ), - ] - return BudgetResponse( - budget=categories, - generated_date=datetime.now().strftime('%Y-%m-%d %H:%M:%S') - ) - -@pytest.fixture -def classifier(): - return CategoryClassifier() - -@pytest.fixture(autouse=True) -def mock_db_queries(): - with patch('src.logic.budgetPerfCalc.get_user_transactions_with_connection') as mock: - yield mock - - -@pytest.fixture -def mock_savings_accounts(): - return [ - {'id': 1, 'balance': 5000}, - {'id': 2, 'balance': 3000}, - ] - -@pytest.fixture -def mock_savings_transactions(): - base_date = datetime.now() - timedelta(days=30) - return [ - {'financialAccount_id': 1, 'amount': 100, 'date': base_date + timedelta(days=5)}, - {'financialAccount_id': 1, 'amount': 200, 'date': base_date + timedelta(days=10)}, - {'financialAccount_id': 2, 'amount': -50, 'date': base_date + timedelta(days=7)}, - {'financialAccount_id': 2, 'amount': 150, 'date': base_date + timedelta(days=15)}, - {'financialAccount_id': 1, 'amount': -75, 'date': base_date + timedelta(days=20)}, - ] -@pytest.fixture -def mock_savings_transactions_by_financial_account(): - return pd.DataFrame([ - {"financialAccountId": "1", "date": "2026-01-10", "amount": 100}, - {"financialAccountId": "1", "date": "2025-01-20", "amount": 50}, - {"financialAccountId": "1", "date": "2026-02-01", "amount": 200}, - ]) - -@pytest.fixture -def mock_incomeflow_transactions(): - base_date = datetime.now() - timedelta(days=30) - return [ - # Income transactions - {'amount': 5000, 'category': 'salary', 'date': base_date}, - {'amount': 200, 'category': 'freelance', 'date': base_date + timedelta(days=7)}, - {'amount': 100, 'category': 'interest', 'date': base_date + timedelta(days=14)}, - # Expense transactions - {'amount': -1500, 'category': 'rent', 'date': base_date + timedelta(days=1)}, - {'amount': -300, 'category': 'groceries', 'date': base_date + timedelta(days=3)}, - {'amount': -200, 'category': 'dining', 'date': base_date + timedelta(days=5)}, - {'amount': -100, 'category': 'entertainment', 'date': base_date + timedelta(days=8)}, - {'amount': -400, 'category': 'utilities', 'date': base_date + timedelta(days=12)}, - ] -@pytest.fixture -def mock_transactions_with_same_amount(): - return [ - {"date": "2026-01-01", "amount": 100}, - {"date": "2026-02-01", "amount": 100}, - {"date": "2026-03-01", "amount": 100}, - {"date": "2026-04-01", "amount": 100}, - {"date": "2026-05-01", "amount": 100}, - ] - -@pytest.fixture -def mock_monthly_diff(): - return pd.DataFrame({ - "month": [1, 2, 3], - "amount": [0, 0, 100], - "balance": [0, 0, 100] - }) - -@pytest.fixture -def mock_debt_payoff_request(): - return DebtPayoffRequest( - id="1", - category="credit_card", - remainingAmount=1000.0, - minimumPayment=200.0, - interestRate=12.0, - nextDueDate="2024-01-01", - period=30 - ) - -@pytest.fixture -def mock_env_vars(): - with patch.dict(os.environ, { - 'JWT_SECRET': 'test_secret_key_12345', - 'MYSQL_HOST': 'localhost', - 'MYSQL_USER': 'test_user', - 'MYSQL_PASSWORD': 'test_password', - 'DB_NAME': 'test_db' - }): - yield - -@pytest.fixture -def valid_token(): - payload = {'sub': '123', 'user_id': '123', 'id': '123'} - return jwt.encode(payload, 'test_secret_key_12345', algorithm='HS256') - -@pytest.fixture -def expired_token(): - import time - payload = {'sub': '123', 'exp': int(time.time()) - 3600} - return jwt.encode(payload, 'test_secret_key_12345', algorithm='HS256') - -@pytest.fixture -def invalid_token(): - return "invalid.token.string" - -@pytest.fixture -def mock_db_connection(): - mock_conn = MagicMock() - mock_cursor = MagicMock() - mock_conn.cursor.return_value = mock_cursor - return mock_conn \ No newline at end of file diff --git a/app/server/services/python/analytics/test/incomeflow.py b/app/server/services/python/analytics/test/incomeflow.py deleted file mode 100644 index 18945bf..0000000 --- a/app/server/services/python/analytics/test/incomeflow.py +++ /dev/null @@ -1,131 +0,0 @@ -# tests/test_service_incomeflow.py -import pytest -import pandas as pd -from src.logic.incomeflow import build_sankey_data - -class TestIncomeflowService: - - def test_build_sankey_data_with_income_and_expenses(self, mock_incomeflow_transactions): - result = build_sankey_data(mock_incomeflow_transactions) - - assert 'nodes' in result - assert 'links' in result - - # Should have total income node - node_names = [node['name'] for node in result['nodes']] - assert 'total income' in node_names - - # Should have all unique categories as nodes - expected_categories = {'salary', 'freelance', 'interest', 'rent', - 'groceries', 'dining', 'entertainment', 'utilities'} - for category in expected_categories: - assert category in node_names - - # Should have links - assert len(result['links']) > 0 - - # Verify link structure - for link in result['links']: - assert 'source' in link - assert 'target' in link - assert 'value' in link - assert isinstance(link['value'], int) - - def test_build_sankey_data_with_overflow(self): - transactions = [ - {'amount': 5000, 'category': 'salary', 'date': '2024-03-01'}, - {'amount': 1000, 'category': 'freelance', 'date': '2024-03-02'}, - {'amount': -1500, 'category': 'rent', 'date': '2024-03-03'}, - {'amount': -500, 'category': 'groceries', 'date': '2024-03-04'}, - ] - - result = build_sankey_data(transactions) - - # Should have 'unspent' node - node_names = [node['name'] for node in result['nodes']] - assert 'unspent' in node_names - - # Should have link to unspent - unspent_index = node_names.index('unspent') - total_income_index = node_names.index('total income') - - # Find link from total income to unspent - unspent_links = [link for link in result['links'] - if link['source'] == total_income_index - and link['target'] == unspent_index] - assert len(unspent_links) == 1 - assert unspent_links[0]['value'] == 4000 # 6000 - 2000 - - def test_build_sankey_data_with_overspending(self): - transactions = [ - {'amount': 3000, 'category': 'salary', 'date': '2024-03-01'}, - {'amount': -1500, 'category': 'rent', 'date': '2024-03-03'}, - {'amount': -1000, 'category': 'groceries', 'date': '2024-03-04'}, - {'amount': -800, 'category': 'dining', 'date': '2024-03-05'}, - ] - - result = build_sankey_data(transactions) - - # Should have 'overspent' and 'savings' nodes - node_names = [node['name'] for node in result['nodes']] - assert 'overspent' in node_names - assert 'savings' in node_names - - # Should have link from savings to overspent - savings_index = node_names.index('savings') - overspent_index = node_names.index('overspent') - - overspent_links = [link for link in result['links'] - if link['source'] == savings_index - and link['target'] == overspent_index] - assert len(overspent_links) == 1 - assert overspent_links[0]['value'] == 300 # 3300 - 3000 - - def test_build_sankey_data_empty_transactions(self): - """Test sankey data with no transactions.""" - result = build_sankey_data([]) - - assert result['nodes'] == [{'name': 'total income'}, {'name': 'no data'}] - assert result['links'] == [] - - def test_build_sankey_data_only_income(self): - transactions = [ - {'amount': 5000, 'category': 'salary', 'date': '2024-03-01'}, - {'amount': 1000, 'category': 'freelance', 'date': '2024-03-02'}, - ] - - result = build_sankey_data(transactions) - - node_names = [node['name'] for node in result['nodes']] - assert 'unspent' in node_names - - # All income should flow to unspent - total_income_index = node_names.index('total income') - unspent_index = node_names.index('unspent') - - unspent_links = [link for link in result['links'] - if link['source'] == total_income_index - and link['target'] == unspent_index] - assert unspent_links[0]['value'] == 6000 - - def test_build_sankey_data_only_expenses(self): - transactions = [ - {'amount': -1500, 'category': 'rent', 'date': '2024-03-03'}, - {'amount': -500, 'category': 'groceries', 'date': '2024-03-04'}, - ] - - result = build_sankey_data(transactions) - - node_names = [node['name'] for node in result['nodes']] - assert 'overspent' in node_names - assert 'savings' in node_names - - # Expenses should come from savings - savings_index = node_names.index('savings') - overspent_index = node_names.index('overspent') - rent_index = node_names.index('rent') - groceries_index = node_names.index('groceries') - - # Links from savings to overspent and to categories - savings_links = [link for link in result['links'] if link['source'] == savings_index] - assert len(savings_links) == 1 # savings into overspent \ No newline at end of file diff --git a/app/server/services/python/analytics/test/periodCalc.py b/app/server/services/python/analytics/test/periodCalc.py deleted file mode 100644 index 8e272e9..0000000 --- a/app/server/services/python/analytics/test/periodCalc.py +++ /dev/null @@ -1,146 +0,0 @@ -import pytest -import pandas as pd -from src.utils.dates import period_calc - -class TestPeriodCalc: - - def test_period_calc_weekly(self): - end_date = pd.Timestamp('2024-03-15') - - start_date, freq, date_format, period_name = period_calc('w', end_date) - - # Check start date is 7 days before end date - expected_start = end_date - pd.Timedelta(days=7) - assert start_date == expected_start - assert start_date.strftime('%Y-%m-%d') == '2024-03-08' - - # Check other return values - assert freq == 'D' - assert date_format == '%Y-%m-%d' - assert period_name == 'Weekly' - - def test_period_calc_monthly(self): - end_date = pd.Timestamp('2024-03-15') - - start_date, freq, date_format, period_name = period_calc('m', end_date) - - # Check start date is 30 days before end date - expected_start = end_date - pd.Timedelta(days=30) - assert start_date == expected_start - assert start_date.strftime('%Y-%m-%d') == '2024-02-14' # March 15 - 30 days = Feb 14 - - # Check other return values - assert freq == 'D' - assert date_format == '%Y-%m-%d' - assert period_name == 'Monthly' - - def test_period_calc_yearly(self): - end_date = pd.Timestamp('2024-03-15') - - start_date, freq, date_format, period_name = period_calc('y', end_date) - - # Check start date is 365 days before end date - expected_start = end_date - pd.Timedelta(days=365) - assert start_date == expected_start - assert start_date.strftime('%Y-%m-%d') == '2023-03-16' # 2024 is leap year - - # Check other return values - assert freq == 'M' - assert date_format == '%Y-%m' - assert period_name == 'Yearly' - - def test_period_calc_with_different_end_dates(self): - test_cases = [ - # (end_date, expected_weekly_start, expected_monthly_start, expected_yearly_start) - ('2024-01-01', '2023-12-25', '2023-12-02', '2023-01-01'), - ('2024-06-15', '2024-06-08', '2024-05-16', '2023-06-16'), - ('2024-12-31', '2024-12-24', '2024-12-01', '2024-01-01'), - ] - - for end_str, expected_weekly, expected_monthly, expected_yearly in test_cases: - end_date = pd.Timestamp(end_str) - - # Test weekly - start, freq, fmt, name = period_calc('w', end_date) - assert start.strftime('%Y-%m-%d') == expected_weekly - - # Test monthly - start, freq, fmt, name = period_calc('m', end_date) - assert start.strftime('%Y-%m-%d') == expected_monthly - - # Test yearly - start, freq, fmt, name = period_calc('y', end_date) - assert start.strftime('%Y-%m-%d') == expected_yearly - - def test_period_calc_edge_cases(self): - - end_date = pd.Timestamp('2024-02-29') # Leap year - start, _, _, _ = period_calc('m', end_date) - assert start.strftime('%Y-%m-%d') == '2024-01-30' # Feb 29 - 30 days = Jan 30 - - end_date = pd.Timestamp('2024-12-31') - start, _, _, _ = period_calc('y', end_date) - assert start.strftime('%Y-%m-%d') == '2024-01-01' - - end_date = pd.Timestamp('2024-01-01') - start, _, _, _ = period_calc('m', end_date) - assert start.strftime('%Y-%m-%d') == '2023-12-02' # Jan 1 - 30 days = Dec 2 - - def test_period_calc_invalid_period(self): - end_date = pd.Timestamp('2024-03-15') - - # Invalid period should go to else branch (yearly) - start, freq, date_format, period_name = period_calc('invalid', end_date) - - expected_start = end_date - pd.Timedelta(days=365) - assert start == expected_start - assert freq == 'M' - assert date_format == '%Y-%m' - assert period_name == 'Yearly' - - def test_period_calc_with_time_component(self): - end_date = pd.Timestamp('2024-03-15 14:30:45') - - start, _, _, _ = period_calc('w', end_date) - - # Time component should be preserved in the calculation - expected_start = end_date - pd.Timedelta(days=7) - assert start == expected_start - assert start.hour == 14 - assert start.minute == 30 - assert start.second == 45 - - def test_period_calc_returns_tuple(self): - end_date = pd.Timestamp('2024-03-15') - result = period_calc('w', end_date) - - assert isinstance(result, tuple) - assert len(result) == 4 - - start, freq, date_format, period_name = result - assert isinstance(start, pd.Timestamp) - assert isinstance(freq, str) - assert isinstance(date_format, str) - assert isinstance(period_name, str) - - @pytest.mark.parametrize("period,expected_freq,expected_format,expected_name", [ - ('w', 'D', '%Y-%m-%d', 'Weekly'), - ('m', 'D', '%Y-%m-%d', 'Monthly'), - ('y', 'M', '%Y-%m', 'Yearly'), - ]) - def test_period_calc_parametrized(self, period, expected_freq, expected_format, expected_name): - end_date = pd.Timestamp('2024-03-15') - - start, freq, date_format, period_name = period_calc(period, end_date) - - assert freq == expected_freq - assert date_format == expected_format - assert period_name == expected_name - - # Verify start date calculation - if period == 'w': - assert start == end_date - pd.Timedelta(days=7) - elif period == 'm': - assert start == end_date - pd.Timedelta(days=30) - else: - assert start == end_date - pd.Timedelta(days=365) \ No newline at end of file diff --git a/app/server/services/python/analytics/test/savings.py b/app/server/services/python/analytics/test/savings.py deleted file mode 100644 index 02d7216..0000000 --- a/app/server/services/python/analytics/test/savings.py +++ /dev/null @@ -1,99 +0,0 @@ -import pytest -import pandas as pd -from datetime import datetime -from unittest.mock import patch, MagicMock -from src.logic.savings import calculate_savings_over_time - -class TestSavings: - - def test_calculate_savings_over_time_weekly(self, mock_savings_accounts, mock_savings_transactions): - end_date = pd.Timestamp('2024-03-15') - start_date = end_date - pd.Timedelta(days=7) - - result = calculate_savings_over_time( - mock_savings_accounts, - mock_savings_transactions, - start_date, - end_date, - 'w', - 'Weekly' - ) - - assert 'labels' in result - assert 'datasets' in result - assert len(result['labels']) == 8 # 7 days + 1? Check your implementation - assert result['datasets'][0]['label'] == 'Weekly Savings' - - # Verify data is numeric - for value in result['datasets'][0]['data']: - assert isinstance(value, (int, float)) - - def test_calculate_savings_over_time_monthly(self, mock_savings_accounts, mock_savings_transactions): - end_date = pd.Timestamp('2024-03-15') - start_date = end_date - pd.Timedelta(days=30) - - result = calculate_savings_over_time( - mock_savings_accounts, - mock_savings_transactions, - start_date, - end_date, - 'm', - 'Monthly' - ) - - assert result['datasets'][0]['label'] == 'Monthly Savings' - assert len(result['labels']) > 0 - - def test_calculate_savings_over_time_yearly(self, mock_savings_accounts, mock_savings_transactions): - end_date = pd.Timestamp('2024-03-15') - start_date = end_date - pd.Timedelta(days=365) - - result = calculate_savings_over_time( - mock_savings_accounts, - mock_savings_transactions, - start_date, - end_date, - 'y', - 'Yearly' - ) - - assert result['datasets'][0]['label'] == 'Yearly Savings' - # Yearly should have monthly labels - assert all(len(label) == 7 for label in result['labels']) # YYYY-MM format - - def test_calculate_savings_over_time_empty_transactions(self, mock_savings_accounts): - end_date = pd.Timestamp('2024-03-15') - start_date = end_date - pd.Timedelta(days=7) - - result = calculate_savings_over_time( - mock_savings_accounts, - [], # Empty transactions - start_date, - end_date, - 'w', - 'Weekly' - ) - - # Should still return structure with zero data - assert 'labels' in result - assert 'datasets' in result - # All savings values should be the initial balances - expected_initial = sum(acc['balance'] for acc in mock_savings_accounts) - assert all(value == expected_initial for value in result['datasets'][0]['data']) - - def test_calculate_savings_over_time_no_accounts(self): - end_date = pd.Timestamp('2024-03-15') - start_date = end_date - pd.Timedelta(days=7) - - result = calculate_savings_over_time( - [], # Empty accounts - [], - start_date, - end_date, - 'w', - 'Weekly' - ) - - # Should return empty structure - assert result['labels'] == [] - assert result['datasets'][0]['data'] == [] \ No newline at end of file diff --git a/app/server/services/ts/auth/Dockerfile.mutation.test b/app/server/services/ts/auth/Dockerfile.mutation.test new file mode 100644 index 0000000..46704fd --- /dev/null +++ b/app/server/services/ts/auth/Dockerfile.mutation.test @@ -0,0 +1,15 @@ +FROM measureonecodetwice/finus-auth:local + +RUN --mount=type=cache,target=/var/cache/apt \ + --mount=type=cache,target=/var/lib/apt/lists \ + apt-get update && apt-get install -y nodejs npm procps \ + && rm -rf /var/lib/apt/lists/* + +COPY auth/test ./test + +WORKDIR /project +COPY vitest.config.ts . + +ENV CI=true + +CMD [ "bun", "stryker", "run"] diff --git a/app/server/services/ts/auth/package.json b/app/server/services/ts/auth/package.json index cad11b2..c04db39 100644 --- a/app/server/services/ts/auth/package.json +++ b/app/server/services/ts/auth/package.json @@ -11,7 +11,8 @@ "scripts": { "dev": "bun --watch src/index.ts", "build": "tsc", - "start": "bun dist/index.js" + "start": "bun dist/index.js", + "test": "vitest run" }, "devDependencies": { "@types/bun": "^1.3.9", diff --git a/app/server/services/ts/auth/stryker.conf.js b/app/server/services/ts/auth/stryker.conf.js new file mode 100644 index 0000000..b2d8c03 --- /dev/null +++ b/app/server/services/ts/auth/stryker.conf.js @@ -0,0 +1,6 @@ +export default { + testRunner: "vitest", + vitest: { + configFile: "vitest.config.ts", + }, +}; diff --git a/app/server/services/ts/auth/vitest.config.ts b/app/server/services/ts/auth/vitest.config.ts new file mode 100644 index 0000000..a6164c5 --- /dev/null +++ b/app/server/services/ts/auth/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['test/**/*.ts'], + }, +}); \ No newline at end of file From 945d27ee91f575b37e5863376e2d0877f3e7a7ec Mon Sep 17 00:00:00 2001 From: Huy Truong Date: Fri, 3 Apr 2026 11:38:50 -0500 Subject: [PATCH 18/23] Eslint checked --- app/server/services/ts/auth/vitest.config.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/server/services/ts/auth/vitest.config.ts b/app/server/services/ts/auth/vitest.config.ts index a6164c5..df9568a 100644 --- a/app/server/services/ts/auth/vitest.config.ts +++ b/app/server/services/ts/auth/vitest.config.ts @@ -1,9 +1,9 @@ -import { defineConfig } from 'vitest/config'; +import { defineConfig } from "vitest/config"; export default defineConfig({ test: { globals: true, - environment: 'node', - include: ['test/**/*.ts'], + environment: "node", + include: ["test/**/*.ts"], }, -}); \ No newline at end of file +}); From 25403b6e1774ee440375dd1621764a93493087d2 Mon Sep 17 00:00:00 2001 From: Roman Tebel Date: Fri, 3 Apr 2026 13:02:09 -0500 Subject: [PATCH 19/23] Corrected test docker file to intall python test dependencies from requirements-test.txt --- app/server/services/python/analytics/Dockerfile.test | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/server/services/python/analytics/Dockerfile.test b/app/server/services/python/analytics/Dockerfile.test index cc722a0..0daf46b 100644 --- a/app/server/services/python/analytics/Dockerfile.test +++ b/app/server/services/python/analytics/Dockerfile.test @@ -3,9 +3,9 @@ FROM measureonecodetwice/finus-analytics:local WORKDIR /app/server/services/python/analytics COPY test ./test -COPY requirements.txt . +COPY requirements-test.txt . -RUN pip install -r requirements.txt +RUN pip install -r requirements-test.txt ENV PYTHONPATH=/app/server/services/python/analytics ENV JWT_SECRET=test_secret_key_12345 From 2db6be6458744710c7dd30c50e494de6c327c5d5 Mon Sep 17 00:00:00 2001 From: Roman Tebel Date: Fri, 3 Apr 2026 13:06:57 -0500 Subject: [PATCH 20/23] Cancelled running mutation tests for TS - stryker does not work on bun --- app/server/docker-compose.test.yml | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/app/server/docker-compose.test.yml b/app/server/docker-compose.test.yml index 08670d5..02c22d7 100644 --- a/app/server/docker-compose.test.yml +++ b/app/server/docker-compose.test.yml @@ -22,15 +22,16 @@ services: dockerfile: Dockerfile.test # # ------ MUTATION TESTS ------ - user-mutation-test: - build: - context: ./services/ts - dockerfile: user/Dockerfile.mutation.test + # THIS CAN'T RUN WITH STRYKER UNLESS WE MOVE TO NODE + # user-mutation-test: + # build: + # context: ./services/ts + # dockerfile: user/Dockerfile.mutation.test - auth-mutation-test: - build: - context: ./services/ts - dockerfile: auth/Dockerfile.mutation.test + # auth-mutation-test: + # build: + # context: ./services/ts + # dockerfile: auth/Dockerfile.mutation.test market-mutation-test: build: From ef4ab3c2ab4c1a3d984112bf7a929c64b3c84c5a Mon Sep 17 00:00:00 2001 From: Roman Tebel Date: Fri, 3 Apr 2026 13:14:21 -0500 Subject: [PATCH 21/23] added back the python fixtures for unit tests --- .../python/analytics/test/conftest.py | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 app/server/services/python/analytics/test/conftest.py diff --git a/app/server/services/python/analytics/test/conftest.py b/app/server/services/python/analytics/test/conftest.py new file mode 100644 index 0000000..1dfc278 --- /dev/null +++ b/app/server/services/python/analytics/test/conftest.py @@ -0,0 +1,167 @@ +import pytest +import pandas as pd +from unittest.mock import patch, MagicMock +from datetime import datetime, timedelta +from src.models.schemas import BudgetCategory, BudgetResponse, DebtPayoffRequest +from src.utils.trans_cat_classifier import CategoryClassifier +import jwt +import os + +@pytest.fixture +def sample_transactions(): + base_date = datetime.now() - timedelta(days=30) + return [ + {'amount': 5000, 'category': 'salary', 'date': base_date}, + {'amount': 200, 'category': 'groceries', 'date': base_date + timedelta(days=2)}, + {'amount': -50, 'category': 'dining', 'date': base_date + timedelta(days=3)}, + {'amount': -100, 'category': 'groceries', 'date': base_date + timedelta(days=5)}, + {'amount': -30, 'category': 'coffee', 'date': base_date + timedelta(days=7)}, + {'amount': -200, 'category': 'entertainment', 'date': base_date + timedelta(days=10)}, + {'amount': 5000, 'category': 'salary', 'date': base_date + timedelta(days=30)}, + ] + +@pytest.fixture +def sample_budget_response(): + categories = [ + BudgetCategory( + category='groceries', + type='need', + avg_monthly_spent=300, + monthly_budget=280, + recommended_budget=65, # weekly + is_essential=True + ), + BudgetCategory( + category='dining', + type='want', + avg_monthly_spent=200, + monthly_budget=150, + recommended_budget=35, # weekly + is_essential=False + ), + BudgetCategory( + category='entertainment', + type='want', + avg_monthly_spent=150, + monthly_budget=120, + recommended_budget=28, # weekly + is_essential=False + ), + ] + return BudgetResponse( + budget=categories, + generated_date=datetime.now().strftime('%Y-%m-%d %H:%M:%S') + ) + +@pytest.fixture +def classifier(): + return CategoryClassifier() + +@pytest.fixture(autouse=True) +def mock_db_queries(): + with patch('src.logic.budgetPerfCalc.get_user_transactions_with_connection') as mock: + yield mock + + +@pytest.fixture +def mock_savings_accounts(): + return [ + {'id': 1, 'balance': 5000}, + {'id': 2, 'balance': 3000}, + ] + +@pytest.fixture +def mock_savings_transactions(): + base_date = datetime.now() - timedelta(days=30) + return [ + {'financialAccount_id': 1, 'amount': 100, 'date': base_date + timedelta(days=5)}, + {'financialAccount_id': 1, 'amount': 200, 'date': base_date + timedelta(days=10)}, + {'financialAccount_id': 2, 'amount': -50, 'date': base_date + timedelta(days=7)}, + {'financialAccount_id': 2, 'amount': 150, 'date': base_date + timedelta(days=15)}, + {'financialAccount_id': 1, 'amount': -75, 'date': base_date + timedelta(days=20)}, + ] +@pytest.fixture +def mock_savings_transactions_by_financial_account(): + return pd.DataFrame([ + {"financialAccountId": "1", "date": "2026-01-10", "amount": 100}, + {"financialAccountId": "1", "date": "2025-01-20", "amount": 50}, + {"financialAccountId": "1", "date": "2026-02-01", "amount": 200}, + ]) + +@pytest.fixture +def mock_incomeflow_transactions(): + base_date = datetime.now() - timedelta(days=30) + return [ + # Income transactions + {'amount': 5000, 'category': 'salary', 'date': base_date}, + {'amount': 200, 'category': 'freelance', 'date': base_date + timedelta(days=7)}, + {'amount': 100, 'category': 'interest', 'date': base_date + timedelta(days=14)}, + # Expense transactions + {'amount': -1500, 'category': 'rent', 'date': base_date + timedelta(days=1)}, + {'amount': -300, 'category': 'groceries', 'date': base_date + timedelta(days=3)}, + {'amount': -200, 'category': 'dining', 'date': base_date + timedelta(days=5)}, + {'amount': -100, 'category': 'entertainment', 'date': base_date + timedelta(days=8)}, + {'amount': -400, 'category': 'utilities', 'date': base_date + timedelta(days=12)}, + ] +@pytest.fixture +def mock_transactions_with_same_amount(): + return [ + {"date": "2026-01-01", "amount": 100}, + {"date": "2026-02-01", "amount": 100}, + {"date": "2026-03-01", "amount": 100}, + {"date": "2026-04-01", "amount": 100}, + {"date": "2026-05-01", "amount": 100}, + ] + +@pytest.fixture +def mock_monthly_diff(): + return pd.DataFrame({ + "month": [1, 2, 3], + "amount": [0, 0, 100], + "balance": [0, 0, 100] + }) + +@pytest.fixture +def mock_debt_payoff_request(): + return DebtPayoffRequest( + id="1", + category="credit_card", + remainingAmount=1000.0, + minimumPayment=200.0, + interestRate=12.0, + nextDueDate="2024-01-01", + period=30 + ) + +@pytest.fixture +def mock_env_vars(): + with patch.dict(os.environ, { + 'JWT_SECRET': 'test_secret_key_12345', + 'MYSQL_HOST': 'localhost', + 'MYSQL_USER': 'test_user', + 'MYSQL_PASSWORD': 'test_password', + 'DB_NAME': 'test_db' + }): + yield + +@pytest.fixture +def valid_token(): + payload = {'sub': '123', 'user_id': '123', 'id': '123'} + return jwt.encode(payload, 'test_secret_key_12345', algorithm='HS256') + +@pytest.fixture +def expired_token(): + import time + payload = {'sub': '123', 'exp': int(time.time()) - 3600} + return jwt.encode(payload, 'test_secret_key_12345', algorithm='HS256') + +@pytest.fixture +def invalid_token(): + return "invalid.token.string" + +@pytest.fixture +def mock_db_connection(): + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_conn.cursor.return_value = mock_cursor + return mock_conn \ No newline at end of file From 11afb6bd771acb51e517b975e79a6253720cb2c2 Mon Sep 17 00:00:00 2001 From: Huy Truong Date: Fri, 3 Apr 2026 13:19:43 -0500 Subject: [PATCH 22/23] Comment out ananlytics mutation test --- app/server/docker-compose.test.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/server/docker-compose.test.yml b/app/server/docker-compose.test.yml index 02c22d7..fa92e8c 100644 --- a/app/server/docker-compose.test.yml +++ b/app/server/docker-compose.test.yml @@ -38,10 +38,10 @@ services: context: services/python/market dockerfile: Dockerfile.mutation.test - analytics-mutation-test: - build: - context: services/python/analytics - dockerfile: Dockerfile.mutation.test + # analytics-mutation-test: + # build: + # context: services/python/analytics + # dockerfile: Dockerfile.mutation.test # MISSING # AUTH From 32c09ceea86ac5be1af476fc5e0e2eeabef5ce20 Mon Sep 17 00:00:00 2001 From: Roman Tebel Date: Fri, 3 Apr 2026 13:22:23 -0500 Subject: [PATCH 23/23] unit test logic fix --- app/server/services/python/analytics/test/test_savings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/server/services/python/analytics/test/test_savings.py b/app/server/services/python/analytics/test/test_savings.py index c214666..ccf647f 100644 --- a/app/server/services/python/analytics/test/test_savings.py +++ b/app/server/services/python/analytics/test/test_savings.py @@ -275,7 +275,7 @@ def test_calculate_compound_interest_different_accounts(self): test_cases = [ (5000, 200, 3.5, 2), # High balance, moderate deposits (100, 500, 7.0, 3), # Low balance, high deposits - (10000, 0, 4.0, 1), # High balance, no deposits + (10000, 1, 4.0, 1), # High balance, no deposits (0, 1000, 6.0, 5), # Zero balance, high deposits ]