nodejs-quickstart-structure 1.15.1 → 1.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +14 -5
  3. package/lib/modules/app-setup.js +3 -3
  4. package/lib/modules/config-files.js +2 -2
  5. package/lib/modules/kafka-setup.js +70 -24
  6. package/package.json +1 -1
  7. package/templates/clean-architecture/js/src/index.js.ejs +9 -6
  8. package/templates/clean-architecture/js/src/infrastructure/webserver/server.js.ejs +12 -11
  9. package/templates/clean-architecture/js/src/interfaces/controllers/userController.js.ejs +17 -1
  10. package/templates/clean-architecture/js/src/interfaces/controllers/userController.spec.js.ejs +36 -0
  11. package/templates/clean-architecture/ts/src/config/swagger.ts.ejs +1 -1
  12. package/templates/clean-architecture/ts/src/index.ts.ejs +16 -16
  13. package/templates/clean-architecture/ts/src/infrastructure/log/logger.spec.ts.ejs +0 -1
  14. package/templates/clean-architecture/ts/src/interfaces/controllers/userController.spec.ts.ejs +19 -0
  15. package/templates/clean-architecture/ts/src/interfaces/controllers/userController.ts.ejs +17 -0
  16. package/templates/common/Dockerfile +2 -0
  17. package/templates/common/README.md.ejs +24 -1
  18. package/templates/common/database/js/models/User.js.ejs +2 -1
  19. package/templates/common/database/ts/models/User.ts.ejs +4 -3
  20. package/templates/common/eslint.config.mjs.ejs +30 -3
  21. package/templates/common/health/js/healthRoute.js.ejs +5 -2
  22. package/templates/common/health/ts/healthRoute.ts.ejs +5 -2
  23. package/templates/common/jest.config.js.ejs +4 -1
  24. package/templates/common/kafka/js/messaging/baseConsumer.js.ejs +30 -0
  25. package/templates/common/kafka/js/messaging/baseConsumer.spec.js.ejs +58 -0
  26. package/templates/common/kafka/js/messaging/userEventSchema.js.ejs +11 -0
  27. package/templates/common/kafka/js/messaging/userEventSchema.spec.js.ejs +27 -0
  28. package/templates/common/kafka/js/messaging/welcomeEmailConsumer.js.ejs +31 -0
  29. package/templates/common/kafka/js/messaging/welcomeEmailConsumer.spec.js.ejs +49 -0
  30. package/templates/common/kafka/js/services/kafkaService.js.ejs +77 -23
  31. package/templates/common/kafka/js/services/kafkaService.spec.js.ejs +53 -7
  32. package/templates/common/kafka/ts/messaging/baseConsumer.spec.ts.ejs +50 -0
  33. package/templates/common/kafka/ts/messaging/baseConsumer.ts.ejs +27 -0
  34. package/templates/common/kafka/ts/messaging/userEventSchema.spec.ts.ejs +51 -0
  35. package/templates/common/kafka/ts/messaging/userEventSchema.ts.ejs +11 -0
  36. package/templates/common/kafka/ts/messaging/welcomeEmailConsumer.spec.ts.ejs +49 -0
  37. package/templates/common/kafka/ts/messaging/welcomeEmailConsumer.ts.ejs +25 -0
  38. package/templates/common/kafka/ts/services/kafkaService.spec.ts.ejs +22 -2
  39. package/templates/common/kafka/ts/services/kafkaService.ts.ejs +65 -12
  40. package/templates/common/package.json.ejs +6 -4
  41. package/templates/common/shutdown/ts/gracefulShutdown.ts.ejs +8 -11
  42. package/templates/mvc/js/src/controllers/userController.js.ejs +15 -0
  43. package/templates/mvc/js/src/controllers/userController.spec.js.ejs +39 -0
  44. package/templates/mvc/js/src/index.js.ejs +20 -15
  45. package/templates/mvc/ts/src/config/swagger.ts.ejs +1 -1
  46. package/templates/mvc/ts/src/controllers/userController.spec.ts.ejs +18 -0
  47. package/templates/mvc/ts/src/controllers/userController.ts.ejs +16 -0
  48. package/templates/mvc/ts/src/index.ts.ejs +16 -18
  49. package/templates/mvc/ts/src/utils/logger.spec.ts.ejs +0 -1
@@ -4,6 +4,7 @@
4
4
  FROM node:22-alpine AS builder
5
5
 
6
6
  WORKDIR /app
7
+ ENV NPM_CONFIG_UPDATE_NOTIFIER=false
7
8
 
8
9
  COPY package*.json ./
9
10
  COPY tsconfig*.json ./
@@ -24,6 +25,7 @@ FROM node:22-alpine AS production
24
25
  WORKDIR /app
25
26
 
26
27
  ENV NODE_ENV=production
28
+ ENV NPM_CONFIG_UPDATE_NOTIFIER=false
27
29
 
28
30
  COPY package*.json ./
29
31
 
@@ -115,6 +115,29 @@ mutation CreateUser {
115
115
  API is exposed via **REST**.
116
116
  A Swagger UI for API documentation is available at:
117
117
  - **URL**: `http://localhost:3000/api-docs` (Dynamic based on PORT)
118
+ <%_ } -%>
119
+
120
+ <% if (communication === 'Kafka') { -%>
121
+ ## 📡 Testing Kafka Asynchronous Flow
122
+ This project demonstrates a production-ready Kafka flow:
123
+ 1. **Producer**: When a user is created via the API, a `USER_CREATED` event is sent to `user-topic`.
124
+ 2. **Consumer**: `WelcomeEmailConsumer` listens to `user-topic` and simulates sending an email.
125
+
126
+ ### How to verify:
127
+ 1. Ensure infrastructure is running: `docker-compose up -d<% if (database !== 'None') { %> db<% } %><% if (caching === 'Redis') { %> redis<% } %><% if (communication === 'Kafka') { %> zookeeper kafka<% } %>`
128
+ 2. Start the app: `npm run dev`
129
+ 3. Trigger an event by creating a user (via Postman or curl):
130
+ ```bash
131
+ curl -X POST http://localhost:3000/api/users \
132
+ -H "Content-Type: application/json" \
133
+ -d '{"name": "Kafka Tester", "email": "kafka@example.com"}'
134
+ ```
135
+ 4. Observe the logs:
136
+ ```text
137
+ [Kafka] Producer: Sent USER_CREATED event for 'kafka@example.com'
138
+ [Kafka] Consumer: Received USER_CREATED.
139
+ [Kafka] Consumer: 📧 Sending welcome email to 'kafka@example.com'... Done!
140
+ ```
118
141
  <% } -%>
119
142
 
120
143
  <% if (caching === 'Redis') { -%>
@@ -227,4 +250,4 @@ This project is "AI-Ready" out of the box. We have pre-configured industry-leadi
227
250
  - **Magic Defaults**: We've automatically tailored your AI context to focus on **<%= projectName %>** and its specific architectural stack (<%= architecture %>, <%= database %>, etc.).
228
251
  - **Use Cursor?** We've configured **`.cursorrules`** at the root. It enforces project standards (70% coverage, MVC/Clean) directly within the editor.
229
252
  - *Pro-tip*: You can customize the `Project Goal` placeholder in `.cursorrules` to help the AI understand your specific business logic!
230
- - **Use ChatGPT/Gemini/Claude?** Check the **`prompts/`** directory. It contains highly-specialized Agent Skill templates. You can copy-paste these into any LLM to give it a "Senior Developer" understanding of your codebase immediately.
253
+ - **Use ChatGPT/Gemini/Claude?** Check the **`prompts/`** directory. It contains highly-specialized Agent Skill templates. You can copy-paste these into any LLM to give it a "Senior Developer" understanding of your codebase immediately.
@@ -11,7 +11,8 @@ class UserModel {
11
11
  }
12
12
 
13
13
  static async create(data) {
14
- const newUser = { id: String(this.mockData.length + 1), ...data };
14
+ const { id, ...rest } = data;
15
+ const newUser = { id: String(this.mockData.length + 1), ...rest };
15
16
  this.mockData.push(newUser);
16
17
  return newUser;
17
18
  }
@@ -16,10 +16,11 @@ export default class UserModel {
16
16
  return this.mockData;
17
17
  }
18
18
 
19
- static async create(data: Omit<User, 'id'>) {
20
- const newUser = { id: String(this.mockData.length + 1), ...data };
19
+ static async create(data: Omit<User, 'id'> & { id?: string | number | null }) {
20
+ const { id, ...rest } = data;
21
+ const newUser: User = { id: String(this.mockData.length + 1), ...rest };
21
22
  this.mockData.push(newUser);
22
- return newUser as User;
23
+ return newUser;
23
24
  }
24
25
  }
25
26
  <% } else { -%>
@@ -1,6 +1,7 @@
1
1
  import eslint from '@eslint/js';
2
2
  import eslintConfigPrettier from 'eslint-config-prettier';
3
3
  import globals from 'globals';
4
+ import importPlugin from 'eslint-plugin-import-x';
4
5
  <% if (language === 'TypeScript') { %>import tseslint from 'typescript-eslint';
5
6
 
6
7
  export default tseslint.config(
@@ -8,6 +9,15 @@ export default tseslint.config(
8
9
  ...tseslint.configs.recommended,
9
10
  eslintConfigPrettier,
10
11
  {
12
+ plugins: {
13
+ import: importPlugin,
14
+ },
15
+ settings: {
16
+ 'import-x/resolver': {
17
+ typescript: true,
18
+ node: true,
19
+ },
20
+ },
11
21
  languageOptions: {
12
22
  globals: {
13
23
  ...globals.node,
@@ -17,16 +27,24 @@ export default tseslint.config(
17
27
  rules: {
18
28
  "no-console": "warn",
19
29
  "no-unused-vars": "off",
20
- "@typescript-eslint/no-unused-vars": "warn",
30
+ "@typescript-eslint/no-unused-vars": ["warn", {
31
+ "argsIgnorePattern": "^_",
32
+ "varsIgnorePattern": "^_"
33
+ }],
21
34
  "@typescript-eslint/no-require-imports": "error",
35
+ "import/no-unresolved": [2, { "caseSensitive": false }]
22
36
  },
23
37
  },
24
38
  {
25
39
  files: ["**/*.test.ts", "**/*.spec.ts", "tests/**/*.ts"],
26
40
  rules: {
27
41
  "@typescript-eslint/no-require-imports": "off",
28
- "@typescript-eslint/no-unused-vars": "warn",
42
+ "@typescript-eslint/no-unused-vars": ["warn", {
43
+ "argsIgnorePattern": "^_",
44
+ "varsIgnorePattern": "^_"
45
+ }],
29
46
  "@typescript-eslint/no-explicit-any": "off",
47
+ "import/no-unresolved": [2, { "caseSensitive": false }]
30
48
  },
31
49
  }
32
50
  );<% } else { %>
@@ -34,6 +52,14 @@ export default [
34
52
  eslint.configs.recommended,
35
53
  eslintConfigPrettier,
36
54
  {
55
+ plugins: {
56
+ import: importPlugin,
57
+ },
58
+ settings: {
59
+ 'import-x/resolver': {
60
+ node: true,
61
+ },
62
+ },
37
63
  languageOptions: {
38
64
  ecmaVersion: 'latest',
39
65
  sourceType: 'module',
@@ -44,7 +70,8 @@ export default [
44
70
  },
45
71
  rules: {
46
72
  "no-console": "warn",
47
- "no-unused-vars": "warn"
73
+ "no-unused-vars": "warn",
74
+ "import/no-unresolved": [2, { "caseSensitive": false }]
48
75
  }
49
76
  }
50
77
  ];<% } %>
@@ -2,6 +2,11 @@ const express = require('express');
2
2
  const router = express.Router();
3
3
  const logger = require('<% if (architecture === "MVC") { %>../utils/logger<% } else { %>../../infrastructure/log/logger<% } %>');
4
4
  const HTTP_STATUS = require('<% if (architecture === "MVC") { %>../utils/httpCodes<% } else { %>../../utils/httpCodes<% } %>');
5
+ <%_ if (database === 'MongoDB') { -%>
6
+ const mongoose = require('mongoose');
7
+ <%_ } else if (database !== 'None') { -%>
8
+ const sequelize = require('<% if (architecture === "MVC") { %>../config/database<% } else { %>../../infrastructure/database/database<% } %>');
9
+ <%_ } -%>
5
10
 
6
11
  router.get('/', async (req, res) => {
7
12
  const healthData = {
@@ -16,7 +21,6 @@ router.get('/', async (req, res) => {
16
21
  <%_ if (database !== 'None') { -%>
17
22
  try {
18
23
  <%_ if (database === 'MongoDB') { -%>
19
- const mongoose = require('mongoose');
20
24
  if (mongoose.connection.readyState === 1) {
21
25
  if (mongoose.connection.db && mongoose.connection.db.admin) {
22
26
  await mongoose.connection.db.admin().ping();
@@ -24,7 +28,6 @@ router.get('/', async (req, res) => {
24
28
  healthData.database = 'connected';
25
29
  }
26
30
  <%_ } else { -%>
27
- const sequelize = require('<% if (architecture === "MVC") { %>../config/database<% } else { %>../../infrastructure/database/database<% } %>');
28
31
  await sequelize.authenticate();
29
32
  healthData.database = 'connected';
30
33
  <%_ } -%>
@@ -1,6 +1,11 @@
1
1
  import { Router, Request, Response } from 'express';
2
2
  import logger from '<% if (architecture === "MVC") { %>@/utils/logger<% } else { %>@/infrastructure/log/logger<% } %>';
3
3
  import { HTTP_STATUS } from '@/utils/httpCodes';
4
+ <%_ if (database === 'MongoDB') { -%>
5
+ import mongoose from 'mongoose';
6
+ <%_ } else if (database !== 'None') { -%>
7
+ import sequelize from '<% if (architecture === "MVC") { %>@/config/database<% } else { %>@/infrastructure/database/database<% } %>';
8
+ <%_ } -%>
4
9
 
5
10
  const router = Router();
6
11
 
@@ -17,13 +22,11 @@ router.get('/', async (req: Request, res: Response) => {
17
22
  <%_ if (database !== 'None') { -%>
18
23
  try {
19
24
  <%_ if (database === 'MongoDB') { -%>
20
- const mongoose = (await import('mongoose')).default;
21
25
  if (mongoose.connection.readyState === 1) {
22
26
  await mongoose.connection.db?.admin().ping();
23
27
  healthData.database = 'connected';
24
28
  }
25
29
  <%_ } else { -%>
26
- const sequelize = (await import('<% if (architecture === "MVC") { %>@/config/database<% } else { %>@/infrastructure/database/database<% } %>')).default;
27
30
  await sequelize.authenticate();
28
31
  healthData.database = 'connected';
29
32
  <%_ } -%>
@@ -13,9 +13,12 @@ module.exports = {
13
13
  "src/index",
14
14
  "src/app",
15
15
  "src/config/env",
16
+ "src/infrastructure/config/env",
16
17
  "src/config/swagger",
17
18
  "src/infrastructure/webserver/swagger",
18
- "src/infrastructure/webserver/server"
19
+ "src/infrastructure/webserver/server",
20
+ "src/utils/logger",
21
+ "src/infrastructure/log/logger"
19
22
  ],
20
23
  coverageThreshold: {
21
24
  global: {
@@ -0,0 +1,30 @@
1
+ const logger = require('<% if (architecture === "Clean Architecture") { %>../../infrastructure/log/logger<% } else { %>../utils/logger<% } %>');
2
+
3
+ class BaseConsumer {
4
+ constructor() {
5
+ if (this.constructor === BaseConsumer) {
6
+ throw new Error("Abstract class 'BaseConsumer' cannot be instantiated.");
7
+ }
8
+ }
9
+
10
+ get topic() { throw new Error("Property 'topic' must be implemented"); }
11
+ get groupId() { throw new Error("Property 'groupId' must be implemented"); }
12
+
13
+ async onMessage({ message }) {
14
+ try {
15
+ const rawValue = message.value?.toString();
16
+ if (!rawValue) return;
17
+
18
+ const data = JSON.parse(rawValue);
19
+ await this.handle(data);
20
+ } catch (error) {
21
+ logger.error(`[Kafka] Error processing message on topic ${this.topic}:`, error);
22
+ }
23
+ }
24
+
25
+ async handle(data) {
26
+ throw new Error("Method 'handle()' must be implemented");
27
+ }
28
+ }
29
+
30
+ module.exports = BaseConsumer;
@@ -0,0 +1,58 @@
1
+ const BaseConsumer = require('<% if (architecture === "Clean Architecture") { %>@/interfaces/messaging/baseConsumer<% } else { %>@/messaging/baseConsumer<% } %>');
2
+ const logger = require('<% if (architecture === "Clean Architecture") { %>@/infrastructure/log/logger<% } else { %>@/utils/logger<% } %>');
3
+
4
+ jest.mock('<% if (architecture === "Clean Architecture") { %>@/infrastructure/log/logger<% } else { %>@/utils/logger<% } %>');
5
+
6
+ class TestConsumer extends BaseConsumer {
7
+ constructor() {
8
+ super();
9
+ this._topic = 'test-topic';
10
+ }
11
+ get topic() { return this._topic; }
12
+ get groupId() { return 'test-group'; }
13
+ async handle(data) {
14
+ logger.info('Handled', data);
15
+ }
16
+ }
17
+
18
+ describe('BaseConsumer', () => {
19
+ let consumer;
20
+
21
+ beforeEach(() => {
22
+ jest.clearAllMocks();
23
+ consumer = new TestConsumer();
24
+ });
25
+
26
+ it('should throw error if instantiated directly', () => {
27
+ expect(() => new BaseConsumer()).toThrow("Abstract class 'BaseConsumer' cannot be instantiated.");
28
+ });
29
+
30
+ it('should process a valid message', async () => {
31
+ const message = { value: Buffer.from(JSON.stringify({ test: 'data' })) };
32
+ await consumer.onMessage({ message });
33
+ expect(logger.info).toHaveBeenCalledWith('Handled', { test: 'data' });
34
+ });
35
+
36
+ it('should handle invalid JSON', async () => {
37
+ const message = { value: Buffer.from('invalid-json') };
38
+ await consumer.onMessage({ message });
39
+ expect(logger.error).toHaveBeenCalledWith(
40
+ expect.stringContaining('[Kafka] Error processing message on topic test-topic:'),
41
+ expect.anything()
42
+ );
43
+ });
44
+
45
+ it('should skip empty messages', async () => {
46
+ const message = { value: null };
47
+ await consumer.onMessage({ message });
48
+ expect(logger.info).not.toHaveBeenCalled();
49
+ });
50
+
51
+ it('should throw if topic, groupId or handle are not implemented', async () => {
52
+ class IncompleteConsumer extends BaseConsumer {}
53
+ const incomplete = new IncompleteConsumer();
54
+ expect(() => incomplete.topic).toThrow("Property 'topic' must be implemented");
55
+ expect(() => incomplete.groupId).toThrow("Property 'groupId' must be implemented");
56
+ await expect(incomplete.handle({})).rejects.toThrow("Method 'handle()' must be implemented");
57
+ });
58
+ });
@@ -0,0 +1,11 @@
1
+ const { z } = require('zod');
2
+
3
+ const UserEventSchema = z.object({
4
+ action: z.enum(['USER_CREATED', 'UPDATE_USER', 'DELETE_USER']),
5
+ payload: z.object({
6
+ id: z.union([z.string(), z.number()]),
7
+ email: z.string().email(),
8
+ }),
9
+ });
10
+
11
+ module.exports = { UserEventSchema };
@@ -0,0 +1,27 @@
1
+ const { UserEventSchema } = require('<% if (architecture === "Clean Architecture") { %>@/interfaces/messaging/schemas/userEventSchema<% } else { %>@/messaging/schemas/userEventSchema<% } %>');
2
+
3
+ describe('UserEventSchema', () => {
4
+ it('should validate a correct USER_CREATED event', () => {
5
+ const validEvent = {
6
+ action: 'USER_CREATED',
7
+ payload: {
8
+ id: 1,
9
+ email: 'test@example.com'
10
+ }
11
+ };
12
+ const result = UserEventSchema.safeParse(validEvent);
13
+ expect(result.success).toBe(true);
14
+ });
15
+
16
+ it('should fail validation for invalid email', () => {
17
+ const invalidEvent = {
18
+ action: 'USER_CREATED',
19
+ payload: {
20
+ id: 1,
21
+ email: 'invalid-email'
22
+ }
23
+ };
24
+ const result = UserEventSchema.safeParse(invalidEvent);
25
+ expect(result.success).toBe(false);
26
+ });
27
+ });
@@ -0,0 +1,31 @@
1
+ const BaseConsumer = require('../../baseConsumer');
2
+ const logger = require('<% if (architecture === "Clean Architecture") { %>../../../../infrastructure/log/logger<% } else { %>../../../utils/logger<% } %>');
3
+ const { UserEventSchema } = require('../../schemas/userEventSchema');
4
+
5
+ class WelcomeEmailConsumer extends BaseConsumer {
6
+ constructor() {
7
+ super();
8
+ }
9
+
10
+ get topic() { return 'user-topic'; }
11
+ get groupId() { return 'welcome-email-group'; }
12
+
13
+ async handle(data) {
14
+ const result = UserEventSchema.safeParse(data);
15
+
16
+ if (!result.success) {
17
+ logger.error('[Kafka] Invalid user event data:', result.error.format());
18
+ return;
19
+ }
20
+
21
+ const { action, payload } = result.data;
22
+
23
+ if (action === 'USER_CREATED') {
24
+ logger.info(`[Kafka] Consumer: Received USER_CREATED.`);
25
+ logger.info(`[Kafka] Consumer: 📧 Sending welcome email to '${payload.email}'... Done!`);
26
+ // In a real app, you would call an EmailService here
27
+ }
28
+ }
29
+ }
30
+
31
+ module.exports = WelcomeEmailConsumer;
@@ -0,0 +1,49 @@
1
+ const WelcomeEmailConsumer = require('<% if (architecture === "Clean Architecture") { %>@/interfaces/messaging/consumers/instances/welcomeEmailConsumer<% } else { %>@/messaging/consumers/instances/welcomeEmailConsumer<% } %>');
2
+ const logger = require('<% if (architecture === "Clean Architecture") { %>@/infrastructure/log/logger<% } else { %>@/utils/logger<% } %>');
3
+
4
+ jest.mock('<% if (architecture === "Clean Architecture") { %>@/infrastructure/log/logger<% } else { %>@/utils/logger<% } %>');
5
+
6
+ describe('WelcomeEmailConsumer', () => {
7
+ let consumer;
8
+
9
+ beforeEach(() => {
10
+ jest.clearAllMocks();
11
+ consumer = new WelcomeEmailConsumer();
12
+ });
13
+
14
+ it('should log welcome email simulation for USER_CREATED action', async () => {
15
+ const data = {
16
+ action: 'USER_CREATED',
17
+ payload: {
18
+ id: 1,
19
+ email: 'test@example.com'
20
+ }
21
+ };
22
+
23
+ await consumer.handle(data);
24
+
25
+ expect(logger.info).toHaveBeenCalledWith(
26
+ expect.stringContaining('[Kafka] Consumer: Received USER_CREATED.')
27
+ );
28
+ expect(logger.info).toHaveBeenCalledWith(
29
+ expect.stringContaining('📧 Sending welcome email to \'test@example.com\'... Done!')
30
+ );
31
+ });
32
+
33
+ it('should log error for invalid data', async () => {
34
+ const data = {
35
+ action: 'USER_CREATED',
36
+ payload: {
37
+ id: 1,
38
+ email: 'invalid-email'
39
+ }
40
+ };
41
+
42
+ await consumer.handle(data);
43
+
44
+ expect(logger.error).toHaveBeenCalledWith(
45
+ expect.stringContaining('[Kafka] Invalid user event data:'),
46
+ expect.anything()
47
+ );
48
+ });
49
+ });
@@ -1,34 +1,88 @@
1
1
  const { kafka } = require('../config/kafka');
2
- const logger = require('<%= loggerPath %>');
2
+ const logger = require('<% if (architecture === "Clean Architecture") { %>../log/logger<% } else { %>../utils/logger<% } %>');
3
3
 
4
4
  let producer = null;
5
5
  let consumer = null;
6
+ let isConnected = false;
7
+ let connectionPromise = null;
6
8
 
7
- const connectKafka = async () => {
8
- if (!producer) producer = kafka.producer();
9
- if (!consumer) consumer = kafka.consumer({ groupId: 'test-group' });
10
-
11
- await producer.connect();
12
- await consumer.connect();
13
- await consumer.subscribe({ topic: 'test-topic', fromBeginning: true });
14
-
15
- await consumer.run({
16
- eachMessage: async ({ _topic, _partition, message }) => {
17
- logger.info({
18
- value: message.value.toString(),
19
- });
20
- },
21
- });
9
+ const connectKafka = async (retries = 10) => {
10
+ if (connectionPromise) return connectionPromise;
11
+
12
+ connectionPromise = (async () => {
13
+ if (!producer) producer = kafka.producer();
14
+ if (!consumer) consumer = kafka.consumer({ groupId: 'test-group' });
15
+
16
+ let attempt = 0;
17
+ // Auto-register WelcomeEmailConsumer if it exists
18
+ // Note: Dynamic import used here for simplicity and to avoid startup crashes.
19
+ // In enterprise production, consider using Dependency Injection.
20
+ const WelcomeEmailConsumer = require('<% if (architecture === "Clean Architecture") { %>../../interfaces/messaging/consumers/instances/welcomeEmailConsumer<% } else { %>../messaging/consumers/instances/welcomeEmailConsumer<% } %>');
21
+ while (attempt < retries) {
22
+ try {
23
+ await producer.connect();
24
+ await consumer.connect();
25
+ logger.info('[Kafka] Producer connected successfully');
26
+ logger.info('[Kafka] Consumer connected successfully');
27
+ isConnected = true;
28
+
29
+ try {
30
+ const welcomeConsumer = new WelcomeEmailConsumer();
31
+ await consumer.subscribe({ topic: welcomeConsumer.topic, fromBeginning: true });
32
+ logger.info(`[Kafka] Registered consumer for topic: ${welcomeConsumer.topic}`);
33
+
34
+ await consumer.run({
35
+ eachMessage: async (payload) => welcomeConsumer.onMessage(payload),
36
+ });
37
+ } catch (error) {
38
+ // Fallback or no consumers found
39
+ await consumer.subscribe({ topic: 'user-topic', fromBeginning: true });
40
+ await consumer.run({
41
+ eachMessage: async ({ message }) => {
42
+ logger.info({ value: message.value.toString() });
43
+ },
44
+ });
45
+ }
46
+ return; // Success
47
+ } catch (error) {
48
+ attempt++;
49
+ logger.error(`[Kafka] Connection attempt ${attempt} failed:`, error.message);
50
+ if (attempt >= retries) {
51
+ throw error; // Rethrow after final attempt
52
+ }
53
+ await new Promise(res => setTimeout(res, 3000)); // Wait 3s between retries
54
+ }
55
+ }
56
+ })();
57
+
58
+ return connectionPromise;
22
59
  };
23
60
 
24
61
  const sendMessage = async (topic, message) => {
25
- if (!producer) producer = kafka.producer();
26
- await producer.send({
27
- topic,
28
- messages: [
29
- { value: message },
30
- ],
31
- });
62
+ if (connectionPromise) {
63
+ await connectionPromise;
64
+ }
65
+
66
+ if (!isConnected) {
67
+ throw new Error('[Kafka] Producer not connected. Check logs for connection errors.');
68
+ }
69
+
70
+ try {
71
+ await producer.send({
72
+ topic,
73
+ messages: [{ value: message }],
74
+ });
75
+
76
+ try {
77
+ const parsed = JSON.parse(message);
78
+ logger.info(`[Kafka] Producer: Sent ${parsed.action} event for '${parsed.payload?.email || 'unknown'}'`);
79
+ } catch (error) {
80
+ logger.info(`[Kafka] Producer: Sent message to ${topic}`, error);
81
+ }
82
+ } catch (error) {
83
+ logger.error(`[Kafka] Failed to send message to ${topic}:`, error.message);
84
+ throw error;
85
+ }
32
86
  };
33
87
 
34
88
  const disconnectKafka = async () => {
@@ -1,7 +1,7 @@
1
1
  let kafka;
2
2
  let connectKafka, sendMessage, disconnectKafka;
3
3
 
4
- jest.mock('<%= configPath %>', () => ({
4
+ jest.mock('<% if (architecture === "Clean Architecture") { %>@/infrastructure/config/kafka<% } else { %>@/config/kafka<% } %>', () => ({
5
5
  kafka: {
6
6
  producer: jest.fn().mockReturnValue({
7
7
  connect: jest.fn().mockResolvedValue(undefined),
@@ -17,15 +17,16 @@ jest.mock('<%= configPath %>', () => ({
17
17
  },
18
18
  }));
19
19
 
20
- jest.mock('<%= loggerPath %>');
20
+ jest.mock('<% if (architecture === "Clean Architecture") { %>@/infrastructure/log/logger<% } else { %>@/utils/logger<% } %>');
21
21
 
22
22
  describe('Kafka Client', () => {
23
23
  beforeEach(async () => {
24
24
  jest.resetModules();
25
25
  jest.clearAllMocks();
26
- kafka = require('<%= configPath %>').kafka;
27
- ({ connectKafka, sendMessage, disconnectKafka } = require('<%= servicePath %>'));
28
- await connectKafka();
26
+ jest.useFakeTimers();
27
+ jest.spyOn(global, 'setTimeout');
28
+ kafka = require('<% if (architecture === "Clean Architecture") { %>@/infrastructure/config/kafka<% } else { %>@/config/kafka<% } %>').kafka;
29
+ ({ connectKafka, sendMessage, disconnectKafka } = require('<% if (architecture === "Clean Architecture") { %>@/infrastructure/messaging/kafkaClient<% } else { %>@/services/kafkaService<% } %>'));
29
30
  });
30
31
 
31
32
  it('should connect producer and consumer', async () => {
@@ -35,11 +36,15 @@ describe('Kafka Client', () => {
35
36
 
36
37
  expect(producer.connect).toHaveBeenCalled();
37
38
  expect(consumer.connect).toHaveBeenCalled();
39
+ expect(consumer.subscribe).toHaveBeenCalledWith(expect.objectContaining({ fromBeginning: true }));
40
+ const subscribeCall = consumer.subscribe.mock.calls.find(call => call[0].topic === 'user-topic' || call[0].topic === 'test-topic');
41
+ expect(subscribeCall).toBeDefined();
38
42
  });
39
43
 
40
44
  it('should send a message', async () => {
45
+ await connectKafka();
41
46
  const topic = 'test-topic';
42
- const message = 'test-message';
47
+ const message = JSON.stringify({ action: 'TEST', payload: { email: 'test@example.com' } });
43
48
  await sendMessage(topic, message);
44
49
  const producer = kafka.producer.mock.results[0].value;
45
50
 
@@ -49,12 +54,53 @@ describe('Kafka Client', () => {
49
54
  });
50
55
  });
51
56
 
57
+ it('should retry connection on failure', async () => {
58
+ // We need to re-require to get a fresh state
59
+ jest.resetModules();
60
+ const { connectKafka: retryConnectKafka } = require('<% if (architecture === "Clean Architecture") { %>@/infrastructure/messaging/kafkaClient<% } else { %>@/services/kafkaService<% } %>');
61
+ const kafkaConfig = require('<% if (architecture === "Clean Architecture") { %>@/infrastructure/config/kafka<% } else { %>@/config/kafka<% } %>');
62
+
63
+ const newProducer = kafkaConfig.kafka.producer();
64
+ // Mock the next producer creation to return our controlled producer
65
+ kafkaConfig.kafka.producer.mockReturnValueOnce(newProducer);
66
+
67
+ newProducer.connect
68
+ .mockRejectedValueOnce(new Error('Connection failed'))
69
+ .mockResolvedValueOnce(undefined);
70
+
71
+ const connectPromise = retryConnectKafka(2);
72
+
73
+ await jest.advanceTimersByTimeAsync(10000);
74
+ await connectPromise;
75
+ expect(newProducer.connect).toHaveBeenCalledTimes(2);
76
+ });
77
+
78
+ it('should throw error if producer not connected', async () => {
79
+ await expect(sendMessage('topic', 'msg')).rejects.toThrow('[Kafka] Producer not connected');
80
+ });
81
+
82
+ it('should log error when sendMessage fails', async () => {
83
+ await connectKafka();
84
+ const producer = kafka.producer.mock.results[0].value;
85
+ producer.send.mockRejectedValue(new Error('Send failed'));
86
+
87
+ await expect(sendMessage('test-topic', 'msg')).rejects.toThrow('Send failed');
88
+ });
89
+
52
90
  it('should disconnect Kafka', async () => {
53
- await disconnectKafka();
91
+ await connectKafka();
54
92
  const producer = kafka.producer.mock.results[0].value;
55
93
  const consumer = kafka.consumer.mock.results[0].value;
56
94
 
95
+ await disconnectKafka();
96
+
57
97
  expect(producer.disconnect).toHaveBeenCalled();
58
98
  expect(consumer.disconnect).toHaveBeenCalled();
59
99
  });
100
+
101
+ it('should allow connecting with custom retries', async () => {
102
+ await connectKafka(5);
103
+ // This is mainly for coverage of the retries parameter
104
+ expect(kafka.producer).toHaveBeenCalled();
105
+ });
60
106
  });