[OWL] Roadmap batch: CI/CD, observability, testing, UX polish
This commit is contained in:
parent
f1c75faba1
commit
0da63ae676
14 changed files with 483 additions and 56 deletions
96
.github/workflows/deploy.yml
vendored
Normal file
96
.github/workflows/deploy.yml
vendored
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
name: FreightDesk CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
env:
|
||||
NODE_VERSION: '20'
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
# ── Lint & Test ──────────────────────────────────────────
|
||||
test:
|
||||
name: Lint & Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
cache-dependency-path: webapp/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./webapp
|
||||
run: npm ci
|
||||
|
||||
- name: Run linter
|
||||
working-directory: ./webapp
|
||||
run: npm run lint --if-present
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./webapp
|
||||
run: npm test --if-present
|
||||
env:
|
||||
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
|
||||
SUPABASE_KEY: ${{ secrets.SUPABASE_KEY }}
|
||||
|
||||
- name: Smoke test
|
||||
working-directory: ./webapp
|
||||
run: |
|
||||
timeout 15 npm start &
|
||||
sleep 5
|
||||
curl -sf http://localhost:3000/health || exit 1
|
||||
echo "✅ Smoke test passed"
|
||||
env:
|
||||
NODE_ENV: test
|
||||
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
|
||||
SUPABASE_KEY: ${{ secrets.SUPABASE_KEY }}
|
||||
SESSION_SECRET: test-secret
|
||||
|
||||
# ── Build & Push Docker Image ────────────────────────────
|
||||
build:
|
||||
name: Build Docker Image
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/master'
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build Docker image
|
||||
working-directory: ./webapp
|
||||
run: |
|
||||
docker build -t freightdesk:${{ github.sha }} .
|
||||
echo "✅ Docker image built"
|
||||
|
||||
- name: Tag image
|
||||
run: |
|
||||
docker tag freightdesk:${{ github.sha }} freightdesk:latest
|
||||
echo "✅ Image tagged"
|
||||
|
||||
# ── Deploy to Coolify ────────────────────────────────────
|
||||
deploy:
|
||||
name: Deploy to Coolify
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/master'
|
||||
steps:
|
||||
- name: Trigger Coolify deployment
|
||||
run: |
|
||||
if [ -n "${{ secrets.COOLIFY_WEBHOOK_URL }}" ]; then
|
||||
curl -sf -X POST "${{ secrets.COOLIFY_WEBHOOK_URL }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"sha": "${{ github.sha }}", "branch": "master"}'
|
||||
echo "✅ Coolify deployment triggered"
|
||||
else
|
||||
echo "⚠️ COOLIFY_WEBHOOK_URL not set — skipping deployment"
|
||||
fi
|
||||
22
webapp/.eslintrc.json
Normal file
22
webapp/.eslintrc.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"env": {
|
||||
"node": true,
|
||||
"es2022": true,
|
||||
"jest": true
|
||||
},
|
||||
"extends": ["eslint:recommended"],
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2022,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
"no-var": "error",
|
||||
"prefer-const": "warn",
|
||||
"no-unused-vars": ["warn", { "argsIgnorePattern": "^(next|req|res)$" }],
|
||||
"semi": ["error", "always"],
|
||||
"no-console": ["warn", { "allow": ["error"] }],
|
||||
"eqeqeq": "error",
|
||||
"curly": "error"
|
||||
},
|
||||
"ignorePatterns": ["public/", "node_modules/", "coverage/"]
|
||||
}
|
||||
13
webapp/.prettierrc.json
Normal file
13
webapp/.prettierrc.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ejs"],
|
||||
"options": { "parser": "html" }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -5,21 +5,44 @@
|
|||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"dev": "node --watch src/server.js",
|
||||
"seed": "node seed.js"
|
||||
"dev": "nodemon src/server.js",
|
||||
"test": "jest --forceExit --detectOpenHandles",
|
||||
"test:unit": "jest tests/unit --forceExit",
|
||||
"test:integration": "jest tests/integration --forceExit --detectOpenHandles",
|
||||
"lint": "eslint src/ --ext .js --max-warnings 0",
|
||||
"format": "prettier --write 'src/**/*.js' 'src/**/*.ejs' 'src/**/*.css'"
|
||||
},
|
||||
"keywords": ["freight", "logistics", "commission", "agent", "india"],
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.45.0",
|
||||
"@supabase/supabase-js": "^2.39.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"compression": "^1.7.4",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"dotenv": "^16.4.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"ejs": "^3.1.9",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"express-session": "^1.18.0",
|
||||
"helmet": "^7.1.0"
|
||||
"express-session": "^1.17.3",
|
||||
"helmet": "^7.1.0",
|
||||
"pino": "^8.17.0",
|
||||
"pino-http": "^9.0.0",
|
||||
"prom-client": "^15.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^8.56.0",
|
||||
"jest": "^29.7.0",
|
||||
"nodemon": "^3.0.2",
|
||||
"prettier": "^3.1.1",
|
||||
"supertest": "^6.3.3"
|
||||
},
|
||||
"jest": {
|
||||
"testEnvironment": "node",
|
||||
"coverageDirectory": "coverage",
|
||||
"collectCoverageFrom": [
|
||||
"src/**/*.js",
|
||||
"!src/server.js"
|
||||
],
|
||||
"testMatch": [
|
||||
"tests/**/*.test.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,9 +8,12 @@ const session = require('express-session');
|
|||
const cookieParser = require('cookie-parser');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const pinoHttp = require('pino-http');
|
||||
const config = require('./config/env');
|
||||
const supabase = require('./services/supabase');
|
||||
const { setupCSRF, validateCSRF, sanitizeBody, requestLogger, asyncHandler } = require('./middleware/security');
|
||||
const logger = require('./services/logger');
|
||||
const metrics = require('./services/metrics');
|
||||
const { setupCSRF, validateCSRF, sanitizeBody, asyncHandler } = require('./middleware/security');
|
||||
const { requireAuth } = require('./middleware/auth');
|
||||
const { formatINR, getStatusColor } = require('./lib/india');
|
||||
|
||||
|
|
@ -35,7 +38,9 @@ app.use(helmet({
|
|||
}));
|
||||
|
||||
app.use(compression());
|
||||
app.use(requestLogger);
|
||||
|
||||
// Pino HTTP logger (replaces requestLogger)
|
||||
app.use(pinoHttp({ logger }));
|
||||
|
||||
// Rate limiting
|
||||
app.use(rateLimit({
|
||||
|
|
@ -51,16 +56,20 @@ app.use(express.json({ limit: '1mb' }));
|
|||
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
|
||||
app.use(cookieParser());
|
||||
|
||||
// Static files
|
||||
// Static files (ETag + 1day cache in production)
|
||||
app.use(express.static(path.join(__dirname, 'public'), {
|
||||
maxAge: config.nodeEnv === 'production' ? '1d' : 0,
|
||||
etag: true,
|
||||
lastModified: true,
|
||||
}));
|
||||
|
||||
// View engine
|
||||
app.set('view engine', 'ejs');
|
||||
app.set('views', path.join(__dirname, 'views'));
|
||||
|
||||
// Cache-busting asset version (changes on restart)
|
||||
const ASSET_VERSION = Date.now();
|
||||
|
||||
// Session
|
||||
app.use(session({
|
||||
secret: config.session.secret,
|
||||
|
|
@ -88,6 +97,7 @@ app.use((req, res, next) => {
|
|||
res.locals.getStatusColor = getStatusColor;
|
||||
res.locals.year = new Date().getFullYear();
|
||||
res.locals._csrf = req.session._csrf;
|
||||
res.locals.assetVersion = ASSET_VERSION;
|
||||
next();
|
||||
});
|
||||
|
||||
|
|
@ -239,6 +249,17 @@ app.use('/reports', require('./routes/reports'));
|
|||
// Health check
|
||||
app.get('/health', (req, res) => res.json({ status: 'ok', ts: Date.now() }));
|
||||
|
||||
// Prometheus metrics
|
||||
app.get('/metrics', async (req, res) => {
|
||||
try {
|
||||
res.set('Content-Type', metrics.register.contentType);
|
||||
res.end(await metrics.register.metrics());
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to collect metrics');
|
||||
res.status(500).end('Internal Server Error');
|
||||
}
|
||||
});
|
||||
|
||||
// 404
|
||||
app.use((req, res) => {
|
||||
res.status(404);
|
||||
|
|
@ -247,15 +268,13 @@ app.use((req, res) => {
|
|||
|
||||
// Error handler
|
||||
app.use((err, req, res, next) => {
|
||||
console.error(`[ERROR] ${req.method} ${req.url}:`, err.message);
|
||||
if (config.nodeEnv === 'development') console.error(err.stack);
|
||||
req.log.error({ err, url: req.url, method: req.method }, 'Unhandled error');
|
||||
res.status(err.status || 500);
|
||||
res.render('pages/500', { error: config.nodeEnv === 'development' ? err.message : null });
|
||||
});
|
||||
|
||||
const server = app.listen(config.port, '::', () => {
|
||||
console.log(`\n🚛 FreightDesk running at http://localhost:${config.port}`);
|
||||
console.log(` Environment: ${config.nodeEnv}`);
|
||||
logger.info({ port: config.port, env: config.nodeEnv }, '🚛 FreightDesk started');
|
||||
console.log(` Press Ctrl+C to stop\n`);
|
||||
});
|
||||
|
||||
|
|
|
|||
11
webapp/src/services/logger.js
Normal file
11
webapp/src/services/logger.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
const pino = require('pino');
|
||||
|
||||
const logger = pino({
|
||||
level: process.env.LOG_LEVEL || (process.env.NODE_ENV === 'production' ? 'info' : 'debug'),
|
||||
transport: process.env.NODE_ENV !== 'production'
|
||||
? { target: 'pino-pretty', options: { colorize: true, translateTime: 'HH:MM:ss.l' } }
|
||||
: undefined,
|
||||
base: { pid: process.pid, env: process.env.NODE_ENV },
|
||||
});
|
||||
|
||||
module.exports = logger;
|
||||
37
webapp/src/services/metrics.js
Normal file
37
webapp/src/services/metrics.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
const client = require('prom-client');
|
||||
|
||||
// Create a Registry
|
||||
const register = new client.Registry();
|
||||
|
||||
// Add default metrics (CPU, memory, etc.)
|
||||
client.collectDefaultMetrics({ register });
|
||||
|
||||
// Custom metrics
|
||||
const httpRequestDuration = new client.Histogram({
|
||||
name: 'http_request_duration_seconds',
|
||||
help: 'Duration of HTTP requests in seconds',
|
||||
labelNames: ['method', 'route', 'status_code'],
|
||||
buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5],
|
||||
});
|
||||
register.registerMetric(httpRequestDuration);
|
||||
|
||||
const httpRequestTotal = new client.Counter({
|
||||
name: 'http_requests_total',
|
||||
help: 'Total number of HTTP requests',
|
||||
labelNames: ['method', 'route', 'status_code'],
|
||||
});
|
||||
register.registerMetric(httpRequestTotal);
|
||||
|
||||
const activeLoads = new client.Gauge({
|
||||
name: 'freightdesk_active_loads',
|
||||
help: 'Number of active (non-settled) loads',
|
||||
});
|
||||
register.registerMetric(activeLoads);
|
||||
|
||||
const totalCommission = new client.Gauge({
|
||||
name: 'freightdesk_total_commission',
|
||||
help: 'Total commission earned (INR)',
|
||||
});
|
||||
register.registerMetric(totalCommission);
|
||||
|
||||
module.exports = { register, httpRequestDuration, httpRequestTotal, activeLoads, totalCommission };
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
<!-- Filters -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<form method="GET" action="/loads" class="filter-bar">
|
||||
<form method="GET" action="/loads" class="filter-bar" id="filterForm">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Status</label>
|
||||
<select name="status" class="form-input" onchange="this.form.submit()">
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Search</label>
|
||||
<input type="text" name="search" class="form-input" placeholder="City, notes..." value="<%= filters.search || '' %>">
|
||||
<input type="text" name="search" class="form-input" placeholder="City, notes..." value="<%= filters.search || '' %>" id="searchInput" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label"> </label>
|
||||
|
|
@ -38,11 +38,14 @@
|
|||
<!-- Loads Table -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div id="loadingSpinner" class="empty-state" style="display:none;">
|
||||
<span>Searching...</span>
|
||||
</div>
|
||||
<% if (loads.length === 0) { %>
|
||||
<p class="empty-state">No loads found. <a href="/loads/new">Add your first load</a></p>
|
||||
<% } else { %>
|
||||
<div class="table-responsive">
|
||||
<table class="table">
|
||||
<table class="table" id="loadsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
|
|
@ -78,4 +81,21 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Debounced search — submits form 400ms after user stops typing
|
||||
(function() {
|
||||
var searchInput = document.getElementById('searchInput');
|
||||
var form = document.getElementById('filterForm');
|
||||
var timer;
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', function() {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(function() {
|
||||
form.submit();
|
||||
}, 400);
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<%- include('../partials/footer') %>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Devanagari:wght@400;600;700&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<link rel="stylesheet" href="/css/style.css?v=<%= typeof assetVersion !== 'undefined' ? assetVersion : '1' %>">
|
||||
</head>
|
||||
<body class="auth-page">
|
||||
<div class="login-page">
|
||||
|
|
|
|||
|
|
@ -1,42 +1,46 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FreightDesk | Admin Setup</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background: #f8f9fa; height: 100vh; display: flex; align-items: center; justify-content: center; }
|
||||
.setup-card { width: 100%; max-width: 450px; box-shadow: 0 10px 25px rgba(0,0,0,0.1); border: none; border-radius: 15px; }
|
||||
.btn-primary { background: #0d6efd; border: none; border-radius: 8px; }
|
||||
</style>
|
||||
<title>Setup — <%= appName %></title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Devanagari:wght@400;600;700&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/css/style.css?v=<%= typeof assetVersion !== 'undefined' ? assetVersion : '1' %>">
|
||||
</head>
|
||||
<body>
|
||||
<div class="setup-card card p-4">
|
||||
<div class="card-body text-center">
|
||||
<h3 class="mb-3">Welcome to FreightDesk</h3>
|
||||
<p class="text-muted mb-4">No administrator account found. Please create your first admin account to get started.</p>
|
||||
<body class="auth-page">
|
||||
<div class="login-page">
|
||||
<div class="login-container">
|
||||
<div class="login-header">
|
||||
<div class="login-emblem">🌐</div>
|
||||
<h1 class="login-title-hi"><%= appNameHi %></h1>
|
||||
<h2 class="login-title-en"><%= appName %> — Initial Setup</h2>
|
||||
<p class="login-tagline">Create your admin account to get started</p>
|
||||
</div>
|
||||
|
||||
<% if (typeof error !== 'undefined' && error) { %>
|
||||
<div class="alert alert-danger py-2 mb-3" role="alert">
|
||||
<%= error %>
|
||||
</div>
|
||||
<div class="alert alert-error"><%= error %></div>
|
||||
<% } %>
|
||||
|
||||
<form action="/setup" method="POST" class="text-start">
|
||||
<div class="mb-3">
|
||||
<form method="POST" action="/setup" class="login-form">
|
||||
<input type="hidden" name="_csrf" value="<%= _csrf %>">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Admin Username</label>
|
||||
<input type="text" name="username" class="form-control" placeholder="e.g. admin_dispatcher" required>
|
||||
<input type="text" name="username" class="form-input" required autofocus placeholder="Choose a username" minlength="3">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Admin Password</label>
|
||||
<input type="password" name="password" class="form-control" placeholder="Enter a strong password" required>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary py-2">Create Admin Account</button>
|
||||
<input type="password" name="password" class="form-input" required placeholder="Choose a strong password" minlength="6">
|
||||
<p class="text-muted" style="font-size:12px;margin-top:4px;">Minimum 6 characters</p>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block">Create Admin Account</button>
|
||||
</form>
|
||||
|
||||
<div class="login-footer">
|
||||
<div class="footer-tricolor"><span></span><span></span><span></span></div>
|
||||
<p>Secured by Government of India</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/js/app.js?v=<%= typeof assetVersion !== 'undefined' ? assetVersion : '1' %>"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
<p class="footer-muted">© <%= year %> <%= appName %> (<%= appNameHi %>). All rights reserved.</p>
|
||||
</footer>
|
||||
|
||||
<script src="/js/app.js"></script>
|
||||
<script src="/js/app.js?v=<%= typeof assetVersion !== 'undefined' ? assetVersion : '1' %>"></script>
|
||||
<% if (typeof extraJs !== 'undefined') { %>
|
||||
<% for (const js of extraJs) { %>
|
||||
<script src="<%= js %>"></script>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Devanagari:wght@400;600;700&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<link rel="stylesheet" href="/css/style.css?v=<%= typeof assetVersion !== 'undefined' ? assetVersion : '1' %>">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="topbar">
|
||||
|
|
|
|||
77
webapp/tests/integration/app.test.js
Normal file
77
webapp/tests/integration/app.test.js
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
const request = require('supertest');
|
||||
const app = require('../../src/server');
|
||||
|
||||
describe('Health Check', () => {
|
||||
test('GET /health returns 200', async () => {
|
||||
const res = await request(app).get('/health');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('ok');
|
||||
expect(res.body.ts).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Metrics Endpoint', () => {
|
||||
test('GET /metrics returns 200 with prometheus format', async () => {
|
||||
const res = await request(app).get('/metrics');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.text).toContain('http_requests_total');
|
||||
expect(res.text).toContain('process_cpu_user_seconds');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Auth Flow', () => {
|
||||
test('GET /login returns 200', async () => {
|
||||
const res = await request(app).get('/login');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.text).toContain('Login');
|
||||
});
|
||||
|
||||
test('GET /setup returns 200 when no users exist', async () => {
|
||||
const res = await request(app).get('/setup');
|
||||
// Should either show setup form or redirect to login
|
||||
expect([200, 302]).toContain(res.status);
|
||||
});
|
||||
|
||||
test('POST /login with empty body returns 200 with error', async () => {
|
||||
const res = await request(app)
|
||||
.post('/login')
|
||||
.send({})
|
||||
.set('Content-Type', 'application/x-www-form-urlencoded');
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test('GET / redirects to /login when not authenticated', async () => {
|
||||
const res = await request(app).get('/');
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toBe('/login');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Protected Routes', () => {
|
||||
test('GET /loads redirects to login', async () => {
|
||||
const res = await request(app).get('/loads');
|
||||
expect(res.status).toBe(302);
|
||||
});
|
||||
|
||||
test('GET /shippers redirects to login', async () => {
|
||||
const res = await request(app).get('/shippers');
|
||||
expect(res.status).toBe(302);
|
||||
});
|
||||
|
||||
test('GET /payments redirects to login', async () => {
|
||||
const res = await request(app).get('/payments');
|
||||
expect(res.status).toBe(302);
|
||||
});
|
||||
|
||||
test('GET /reports redirects to login', async () => {
|
||||
const res = await request(app).get('/reports');
|
||||
expect(res.status).toBe(302);
|
||||
});
|
||||
});
|
||||
|
||||
describe('404 Handler', () => {
|
||||
test('GET /nonexistent returns 404', async () => {
|
||||
const res = await request(app).get('/nonexistent-page');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
105
webapp/tests/unit/utils.test.js
Normal file
105
webapp/tests/unit/utils.test.js
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
const { formatINR, getStatusColor, calcCommission, calcPendingFromShipper, calcPendingToDriver } = require('../../src/lib/india');
|
||||
const { parseWhatsAppMessage } = require('../../src/services/parser');
|
||||
|
||||
describe('India Utils — formatINR', () => {
|
||||
test('formats whole numbers', () => {
|
||||
expect(formatINR(1000)).toBe('₹1,000');
|
||||
expect(formatINR(100000)).toBe('₹1,00,000');
|
||||
expect(formatINR(10000000)).toBe('₹1,00,00,000');
|
||||
});
|
||||
|
||||
test('formats decimals', () => {
|
||||
expect(formatINR(1999.50)).toBe('₹1,999.5');
|
||||
});
|
||||
|
||||
test('handles zero', () => {
|
||||
expect(formatINR(0)).toBe('₹0');
|
||||
});
|
||||
|
||||
test('handles null/undefined', () => {
|
||||
expect(formatINR(null)).toBe('—');
|
||||
expect(formatINR(undefined)).toBe('—');
|
||||
});
|
||||
});
|
||||
|
||||
describe('India Utils — getStatusColor', () => {
|
||||
test('returns correct badge colors', () => {
|
||||
expect(getStatusColor('settled')).toBe('green');
|
||||
expect(getStatusColor('completed')).toBe('green');
|
||||
expect(getStatusColor('loaded / in transit')).toBe('blue');
|
||||
expect(getStatusColor('pending collection')).toBe('orange');
|
||||
expect(getStatusColor('cancelled')).toBe('red');
|
||||
expect(getStatusColor('unknown')).toBe('gray');
|
||||
});
|
||||
});
|
||||
|
||||
describe('India Utils — calcCommission', () => {
|
||||
test('calculates commission correctly', () => {
|
||||
expect(calcCommission(19000, 15900)).toBe(3100);
|
||||
expect(calcCommission(50000, 45000)).toBe(5000);
|
||||
});
|
||||
|
||||
test('returns null for missing values', () => {
|
||||
expect(calcCommission(null, 100)).toBeNull();
|
||||
expect(calcCommission(100, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('India Utils — calcPendingFromShipper', () => {
|
||||
test('calculates pending correctly', () => {
|
||||
expect(calcPendingFromShipper(19000, 5000)).toBe(14000);
|
||||
});
|
||||
|
||||
test('returns null for missing values', () => {
|
||||
expect(calcPendingFromShipper(null, 100)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('India Utils — calcPendingToDriver', () => {
|
||||
test('calculates pending correctly', () => {
|
||||
expect(calcPendingToDriver(15900, 10000)).toBe(5900);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhatsApp Parser', () => {
|
||||
test('parses a standard message', () => {
|
||||
const msg = 'Agarwal Bangalore TN39DV8142 loaded 19000 freight driver advance 15900';
|
||||
const result = parseWhatsAppMessage(msg);
|
||||
|
||||
expect(result.shipper).toBe('Agarwal Packers and Movers');
|
||||
expect(result.to_city).toBe('Bangalore');
|
||||
expect(result.vehicle).toBe('TN39DV8142');
|
||||
expect(result.freight_charged).toBe(19000);
|
||||
expect(result.paid_to_driver).toBe(15900);
|
||||
expect(result.commission).toBe(3100);
|
||||
expect(result.confidence).toBe('high');
|
||||
});
|
||||
|
||||
test('parses message with Mumbai route', () => {
|
||||
const msg = 'Kahn Transport Mumbai MH12AB1234 loaded 45000 freight advance 40000';
|
||||
const result = parseWhatsAppMessage(msg);
|
||||
|
||||
expect(result.shipper).toBe('Kahn Transport');
|
||||
expect(result.to_city).toBe('Mumbai');
|
||||
expect(result.freight_charged).toBe(45000);
|
||||
});
|
||||
|
||||
test('parses status keywords', () => {
|
||||
const msg1 = 'Agarwal Chennai TN39DV8142 loaded 20000 freight';
|
||||
expect(parseWhatsAppMessage(msg1).status).toBe('loaded / in transit');
|
||||
|
||||
const msg2 = 'Agarwal Chennai TN39DV8142 delivered 20000 freight';
|
||||
expect(parseWhatsAppMessage(msg2).status).toBe('delivered / pending collection');
|
||||
});
|
||||
|
||||
test('handles empty message', () => {
|
||||
const result = parseWhatsAppMessage('');
|
||||
expect(result.confidence).toBe('low');
|
||||
});
|
||||
|
||||
test('extracts vehicle number', () => {
|
||||
const msg = 'Some text KL01AB1234 more text';
|
||||
const result = parseWhatsAppMessage(msg);
|
||||
expect(result.vehicle).toBe('KL01AB1234');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue