feat: stabilize API startup and lifecycle management

- Refactor server initialization to separate concerns and improve error handling.
- Implement centralized environment validation using Zod.
- Introduce database, Redis, and queue lifecycle management.
- Add health check endpoints for liveness and readiness.
- Enhance error handling middleware for better response structure.
- Implement rate limiting for API endpoints.
- Add request ID middleware for traceability.
- Create Sequelize CLI configuration and baseline migration for schema management.
- Establish CI workflow with Gitea for testing and syntax checks.
- Document foundational changes and migration strategy in PHASE_0_FOUNDATION_STABILIZATION.md.
- Add Docker Compose configuration for local development and testing.
- Implement unit and integration tests for critical functionality.
This commit is contained in:
Sathira Sri Sathara
2026-09-03 13:33:26 +05:30
parent 624fce31c5
commit 267e80e2ec
40 changed files with 1682 additions and 261 deletions
+77
View File
@@ -0,0 +1,77 @@
const express = require("express");
const request = require("supertest");
const mockQueue = { add: jest.fn(), close: jest.fn(), on: jest.fn(), getJob: jest.fn() };
const mockCheckDatabase = jest.fn();
const mockCheckRedis = jest.fn();
jest.mock("../../app/queues/activity.queue", () => mockQueue);
jest.mock("../../app/queues/document.queue", () => mockQueue);
jest.mock("../../app/queues/log.queue", () => mockQueue);
jest.mock("../../app/config/redisClient", () => ({
status: "wait", connect: jest.fn(), ping: jest.fn(), get: jest.fn(), set: jest.fn(), del: jest.fn(), quit: jest.fn(),
}));
jest.mock("../../app/config/database.lifecycle", () => ({ checkDatabase: mockCheckDatabase }));
jest.mock("../../app/config/redis.lifecycle", () => ({ checkRedis: mockCheckRedis }));
jest.mock("../../app/config/bullBoard.config", () => {
const express = require("express");
return { bullBoardRouter: express.Router().get("/", (_req, res) => res.json({ ok: true })) };
});
const app = require("../../app");
const { AppError, errorHandler } = require("../../app/middleware/error.middleware");
describe("foundation HTTP behavior", () => {
beforeEach(() => {
mockCheckDatabase.mockResolvedValue(true);
mockCheckRedis.mockResolvedValue(true);
});
test("GET /health/live reports process liveness", async () => {
const response = await request(app).get("/health/live").expect(200);
expect(response.body).toMatchObject({ status: "ok", service: "zumri-api" });
expect(response.body.timestamp).toBeDefined();
expect(response.headers["x-request-id"]).toBeDefined();
});
test("legacy GET /health remains a liveness alias", async () => {
await request(app).get("/health").expect(200).expect(({ body }) => expect(body.status).toBe("ok"));
});
test("GET /health/ready checks database and Redis", async () => {
await request(app).get("/health/ready").expect(200).expect(({ body }) => {
expect(body).toEqual({ status: "ready", checks: { database: "ok", redis: "ok" } });
});
expect(mockCheckDatabase).toHaveBeenCalled();
expect(mockCheckRedis).toHaveBeenCalled();
});
test("GET /health/ready returns 503 when a dependency fails", async () => {
mockCheckDatabase.mockResolvedValueOnce(false);
await request(app).get("/health/ready").expect(503).expect(({ body }) => {
expect(body.status).toBe("not_ready");
expect(body.checks.database).toBe("error");
});
});
test("unknown routes use the centralized 404 response", async () => {
const response = await request(app).get("/does-not-exist").expect(404);
expect(response.body.error).toMatchObject({ code: "NOT_FOUND", message: "Route not found" });
});
test("global errors use the standard response and request ID", async () => {
const errorApp = express();
errorApp.use(require("../../app/middleware/requestId.middleware"));
errorApp.get("/error", (_req, _res, next) => next(new AppError(400, "TEST_ERROR", "Controlled failure")));
errorApp.use(errorHandler);
const response = await request(errorApp).get("/error").expect(400);
expect(response.body.error).toEqual({ code: "TEST_ERROR", message: "Controlled failure" });
expect(response.body.requestId).toBeDefined();
});
test("an authenticated route rejects missing credentials", async () => {
await request(app).get("/api/auth/me").expect(401).expect(({ body }) => expect(body.success).toBe(false));
});
test("Bull Board rejects unauthenticated requests", async () => {
await request(app).get("/admin/queues").expect(401);
});
});
+14
View File
@@ -0,0 +1,14 @@
process.env.NODE_ENV = "test";
process.env.PORT = "3070";
process.env.DB_HOST = "localhost";
process.env.DB_PORT = "3306";
process.env.DB_NAME = "zumri_test";
process.env.DB_USER = "zumri_test";
process.env.DB_PASSWORD = "test-only-password";
process.env.JWT_SECRET = "test-only-jwt-secret-value-32-characters";
process.env.REFRESH_TOKEN_SECRET = "test-only-refresh-secret-value-32-chars";
process.env.REDIS_HOST = "localhost";
process.env.REDIS_PORT = "6379";
process.env.FRONTEND_URL = "http://localhost:3000";
process.env.RUN_CRON = "false";
process.env.CACHE = "false";
+21
View File
@@ -0,0 +1,21 @@
const { validateEnvironment, getOptionalFeatureStatus } = require("../../app/config/env.config");
const valid = {
NODE_ENV: "test", PORT: "3070", DB_HOST: "localhost", DB_PORT: "3306", DB_NAME: "test",
DB_USER: "test", DB_PASSWORD: "", JWT_SECRET: "a".repeat(32), REFRESH_TOKEN_SECRET: "b".repeat(32),
REDIS_HOST: "localhost", REDIS_PORT: "6379", FRONTEND_URL: "http://localhost:3000",
};
describe("environment validation", () => {
test("accepts the critical API configuration", () => {
expect(validateEnvironment(valid)).toMatchObject({ PORT: 3070, DB_NAME: "test", RUN_CRON: false });
});
test("fails safely and names invalid variables without values", () => {
expect(() => validateEnvironment({ ...valid, JWT_SECRET: "short" })).toThrow("JWT_SECRET");
});
test("keeps mail and S3 optional", () => {
expect(getOptionalFeatureStatus(valid)).toMatchObject({ mail: false, s3: false });
});
});