Skip to content

Feat/compt 82 custom indicator api and health service#14

Closed
saadmoumou wants to merge 37 commits intomasterfrom
feat/COMPT-82-custom-indicator-api-and-health-service
Closed

Feat/compt 82 custom indicator api and health service#14
saadmoumou wants to merge 37 commits intomasterfrom
feat/COMPT-82-custom-indicator-api-and-health-service

Conversation

@saadmoumou
Copy link
Copy Markdown

Summary

  • What does this PR change?

Why

  • Why is this change needed?

Checklist

  • Added/updated tests (if behavior changed)
  • npm run lint passes
  • npm run typecheck passes
  • npm test passes
  • npm run build passes
  • Added a changeset (npx changeset) if this affects consumers

Notes

  • Anything reviewers should pay attention to?

@saadmoumou saadmoumou requested review from a team and Copilot April 2, 2026 13:41
@saadmoumou saadmoumou closed this Apr 2, 2026
@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud bot commented Apr 2, 2026

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Introduces a new @ciscode/health-kit NestJS module that provides liveness/readiness endpoints backed by pluggable health indicators (built-in + custom indicator APIs), along with supporting TS/Jest path aliases and updated repo tooling.

Changes:

  • Added HealthKitModule, HealthService, and a controller factory exposing /health/live and /health/ready.
  • Implemented built-in health indicators (Postgres, Redis, HTTP) plus custom-indicator helpers (factory + base class + decorator).
  • Updated package identity/tooling (package rename, TS/Jest aliases, ESLint config, Husky + lint-staged).

Reviewed changes

Copilot reviewed 26 out of 29 changed files in this pull request and generated 14 comments.

Show a summary per file
File Description
tsconfig.json Adds @interfaces/* and @indicators/* path aliases.
tsconfig.build.json Forces CommonJS + Node module resolution for build output.
src/services/health.service.ts Adds orchestration service for liveness/readiness checks.
src/services/health.service.spec.ts Adds unit tests for health service behavior (currently mismatched with response shape).
src/interfaces/health-indicator.interface.ts Defines the IHealthIndicator contract and result types.
src/indicators/redis.indicator.ts Adds Redis health indicator with timeout behavior.
src/indicators/redis.indicator.spec.ts Tests Redis indicator behavior.
src/indicators/postgres.indicator.ts Adds Postgres health indicator with timeout behavior.
src/indicators/postgres.indicator.spec.ts Tests Postgres indicator behavior.
src/indicators/http.indicator.ts Adds HTTP health indicator using Node 20+ fetch.
src/indicators/http.indicator.spec.ts Tests HTTP indicator behavior via mocked fetch.
src/indicators/create-indicator.ts Adds helper factory for inline async-function indicators.
src/indicators/create-indicator.spec.ts Tests the custom indicator factory (currently leaves timers running on success).
src/indicators/base.indicator.ts Adds base class for DI-based custom indicators.
src/indicators/base.indicator.spec.ts Tests the base indicator helper behavior.
src/indicators/mongo.indicator.spec.ts Adds Mongo indicator tests (implementation missing in this PR).
src/decorators/health-indicator.decorator.ts Adds decorator for indicator scoping metadata.
src/decorators/health-indicator.decorator.spec.ts Tests decorator metadata behavior (minor typo in class name).
src/controllers/health.controller.ts Adds controller factory wired to HealthService returning 200/503.
src/controllers/health.controller.spec.ts Tests controller factory behavior (stub mismatched with service result shape).
src/health-kit.module.ts Adds dynamic module registration (sync/async) and endpoint controller creation.
src/index.ts Replaces template exports with health-kit public API exports.
package.json Renames package to @ciscode/health-kit and updates peer deps.
package-lock.json Updates lockfile metadata/dependencies to match package changes.
jest.config.ts Adds Jest moduleNameMapper entries for new TS aliases.
eslint.config.mjs Replaces prior config file and expands ignore/ruleset (uses import.meta.dirname).
eslint.config.js Removes the old ESLint config file.
lint-staged.config.mjs Adds lint-staged rules (currently runs eslint . --fix on full repo).
.husky/pre-commit Simplifies hook to only run lint-staged (currently missing Husky shim/shebang).

Comment on lines +7 to +10
export interface HealthCheckResult {
status: "ok" | "error";
results: HealthIndicatorResult[];
}
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HealthCheckResult exposes a results property, but tests/controllers in this PR use indicators. This inconsistency will break consumers and causes the current unit tests to fail at runtime. Align the public contract and usages (either rename results -> indicators everywhere, or update all call sites/tests to use results).

Copilot uses AI. Check for mistakes.
Comment on lines +27 to +33
it("returns status 'ok' when all liveness indicators are up", async () => {
const service = new HealthService([mockIndicator(up("proc"))], []);
const result = await service.checkLiveness();

expect(result.status).toBe("ok");
expect(result.indicators).toEqual([up("proc")]);
});
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests assert on result.indicators, but HealthService.checkLiveness() returns HealthCheckResult with a results array. These expectations will fail and should be updated to the actual response shape (or rename the service response property for consistency).

Copilot uses AI. Check for mistakes.
Comment on lines +86 to +91
const result = await service.checkReadiness();

expect(result.status).toBe("error");
const failed = result.indicators.find((r) => r.status === "down");
expect(failed?.message).toBe("ECONNREFUSED");
});
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test uses result.indicators.find(...), but the service response uses results. Update the test (and any other callers) to use the correct property name, otherwise the test will fail and won’t validate the intended behavior.

Copilot uses AI. Check for mistakes.
Comment on lines +13 to +14
checkLiveness: jest.fn().mockResolvedValue({ status: liveness, indicators: [] }),
checkReadiness: jest.fn().mockResolvedValue({ status: readiness, indicators: [] }),
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The controller test stub returns { status, indicators: [] }, but the exported HealthCheckResult type uses results. The as unknown as HealthService cast hides the mismatch and makes this test diverge from real behavior. Stub the service with { status, results: [] } to match the actual contract.

Suggested change
checkLiveness: jest.fn().mockResolvedValue({ status: liveness, indicators: [] }),
checkReadiness: jest.fn().mockResolvedValue({ status: readiness, indicators: [] }),
checkLiveness: jest.fn().mockResolvedValue({ status: liveness, results: [] }),
checkReadiness: jest.fn().mockResolvedValue({ status: readiness, results: [] }),

Copilot uses AI. Check for mistakes.
Comment on lines +6 to +13
/**
* Factory that creates an {@link IHealthIndicator} from an inline async function.
*
* The check function may return:
* - `false` → indicator reports `"down"`
* - `true` or `void` → indicator reports `"up"`
* - throws → indicator reports `"down"` with the error message
*
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createIndicator() docs state that thrown errors are converted into a "down" result, but the implementation lets rejections propagate (and the spec asserts that). This makes the indicator violate the IHealthIndicator.check(): Promise<HealthIndicatorResult> contract and leads to less-informative names in HealthService (e.g. "Object" on failures). Either wrap exceptions/timeouts into a HealthIndicatorResult or update the docs and downstream handling to intentionally allow throws.

Copilot uses AI. Check for mistakes.

class SomeIndicator {}
class AnotherIndicator {}
class UndecotratedIndicator {}
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo in test helper class name: UndecotratedIndicator should be UndecoratedIndicator for readability and consistency with the test description (“undecorated”).

Copilot uses AI. Check for mistakes.
Comment on lines +39 to +43
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`${name} timed out after ${timeout}ms`)), timeout),
);

return Promise.race([run(), timeoutPromise]);
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The timeout setTimeout started for timeoutPromise is never cleared when checkFn completes quickly. That leaves an active timer handle for up to timeout ms per check, which can slow test runs and keep Node processes alive unnecessarily. Store the timer id and clearTimeout it in a finally once Promise.race settles.

Suggested change
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`${name} timed out after ${timeout}ms`)), timeout),
);
return Promise.race([run(), timeoutPromise]);
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(
() => reject(new Error(`${name} timed out after ${timeout}ms`)),
timeout,
);
});
try {
return await Promise.race([run(), timeoutPromise]);
} finally {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +43 to +60
async check(): Promise<HealthIndicatorResult> {
try {
await Promise.race([this.client.ping(), this._timeout()]);
return { name: "redis", status: "up" };
} catch (error) {
return {
name: "redis",
status: "down",
message: error instanceof Error ? error.message : "Unknown error",
};
}
}

private _timeout(): Promise<never> {
return new Promise((_, reject) =>
setTimeout(() => reject(new Error("Timeout")), this.timeoutMs),
);
}
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_timeout() schedules a setTimeout but it is never cleared when ping() resolves/rejects first. This leaves an active timer handle for up to timeoutMs per check, which can keep the process running longer than necessary and cause Jest “open handles” warnings. Create/clear the timeout within check() (or return a timeout promise that exposes a cancel function) so the timer is always cleaned up.

Copilot uses AI. Check for mistakes.
Comment on lines +42 to +59
async check(): Promise<HealthIndicatorResult> {
try {
await Promise.race([this.client.query("SELECT 1"), this._timeout()]);
return { name: "postgres", status: "up" };
} catch (error) {
return {
name: "postgres",
status: "down",
message: error instanceof Error ? error.message : "Unknown error",
};
}
}

private _timeout(): Promise<never> {
return new Promise((_, reject) =>
setTimeout(() => reject(new Error("Timeout")), this.timeoutMs),
);
}
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_timeout() starts a setTimeout that is never cleared when the query finishes before the timeout. This leaves active timer handles that can keep the Node event loop alive and trigger Jest “open handles” warnings. Prefer creating the timer in check() and clearing it in a finally after Promise.race settles.

Copilot uses AI. Check for mistakes.
Comment on lines +35 to +40
parser: tseslint.parser,
parserOptions: {
projectService: true,
project: "./tsconfig.eslint.json",
tsconfigRootDir: import.meta.dirname,
ecmaVersion: "latest",
sourceType: "module",
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tsconfigRootDir: import.meta.dirname requires Node’s import.meta.dirname support; since engines.node is >=20 (not a specific minor), this may break on Node 20 versions that don’t implement import.meta.dirname. To keep linting portable, derive the directory from import.meta.url (or bump the minimum Node engine to a version that guarantees import.meta.dirname).

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants