nodejs-quickstart-structure 1.14.0 → 1.16.0
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.
- package/CHANGELOG.md +32 -0
- package/bin/index.js +84 -80
- package/lib/generator.js +11 -1
- package/lib/modules/app-setup.js +3 -3
- package/lib/modules/config-files.js +27 -2
- package/lib/modules/kafka-setup.js +70 -24
- package/package.json +8 -3
- package/templates/clean-architecture/js/src/index.js.ejs +1 -3
- package/templates/clean-architecture/js/src/infrastructure/webserver/server.js.ejs +11 -10
- package/templates/clean-architecture/js/src/interfaces/controllers/userController.js.ejs +16 -1
- package/templates/clean-architecture/js/src/interfaces/controllers/userController.spec.js.ejs +36 -0
- package/templates/clean-architecture/ts/src/config/swagger.ts.ejs +1 -1
- package/templates/clean-architecture/ts/src/index.ts.ejs +12 -14
- package/templates/clean-architecture/ts/src/infrastructure/log/logger.spec.ts.ejs +0 -1
- package/templates/clean-architecture/ts/src/interfaces/controllers/userController.spec.ts.ejs +19 -0
- package/templates/clean-architecture/ts/src/interfaces/controllers/userController.ts.ejs +16 -0
- package/templates/common/.cursorrules.ejs +60 -0
- package/templates/common/.dockerignore +2 -0
- package/templates/common/.gitlab-ci.yml.ejs +5 -5
- package/templates/common/Dockerfile +2 -0
- package/templates/common/Jenkinsfile.ejs +1 -1
- package/templates/common/README.md.ejs +34 -1
- package/templates/common/_github/workflows/ci.yml +7 -4
- package/templates/common/database/js/models/User.js.ejs +2 -1
- package/templates/common/database/ts/models/User.ts.ejs +4 -3
- package/templates/common/eslint.config.mjs.ejs +30 -3
- package/templates/common/jest.config.js.ejs +4 -1
- package/templates/common/kafka/js/messaging/baseConsumer.js.ejs +30 -0
- package/templates/common/kafka/js/messaging/baseConsumer.spec.js.ejs +58 -0
- package/templates/common/kafka/js/messaging/userEventSchema.js.ejs +11 -0
- package/templates/common/kafka/js/messaging/userEventSchema.spec.js.ejs +27 -0
- package/templates/common/kafka/js/messaging/welcomeEmailConsumer.js.ejs +31 -0
- package/templates/common/kafka/js/messaging/welcomeEmailConsumer.spec.js.ejs +49 -0
- package/templates/common/kafka/js/services/kafkaService.js.ejs +75 -23
- package/templates/common/kafka/js/services/kafkaService.spec.js.ejs +53 -7
- package/templates/common/kafka/ts/messaging/baseConsumer.spec.ts.ejs +50 -0
- package/templates/common/kafka/ts/messaging/baseConsumer.ts.ejs +27 -0
- package/templates/common/kafka/ts/messaging/userEventSchema.spec.ts.ejs +51 -0
- package/templates/common/kafka/ts/messaging/userEventSchema.ts.ejs +11 -0
- package/templates/common/kafka/ts/messaging/welcomeEmailConsumer.spec.ts.ejs +49 -0
- package/templates/common/kafka/ts/messaging/welcomeEmailConsumer.ts.ejs +25 -0
- package/templates/common/kafka/ts/services/kafkaService.spec.ts.ejs +22 -2
- package/templates/common/kafka/ts/services/kafkaService.ts.ejs +72 -12
- package/templates/common/package.json.ejs +6 -4
- package/templates/common/prompts/add-feature.md.ejs +26 -0
- package/templates/common/prompts/project-context.md.ejs +43 -0
- package/templates/common/prompts/troubleshoot.md.ejs +28 -0
- package/templates/mvc/js/src/controllers/userController.js.ejs +14 -0
- package/templates/mvc/js/src/controllers/userController.spec.js.ejs +39 -0
- package/templates/mvc/js/src/index.js.ejs +12 -11
- package/templates/mvc/ts/src/config/swagger.ts.ejs +1 -1
- package/templates/mvc/ts/src/controllers/userController.spec.ts.ejs +18 -0
- package/templates/mvc/ts/src/controllers/userController.ts.ejs +16 -0
- package/templates/mvc/ts/src/index.ts.ejs +13 -16
- package/templates/mvc/ts/src/utils/logger.spec.ts.ejs +0 -1
|
@@ -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,86 @@
|
|
|
1
1
|
const { kafka } = require('../config/kafka');
|
|
2
|
-
const logger = require('
|
|
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 (
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
+
while (attempt < retries) {
|
|
18
|
+
try {
|
|
19
|
+
await producer.connect();
|
|
20
|
+
await consumer.connect();
|
|
21
|
+
logger.info('[Kafka] Producer connected successfully');
|
|
22
|
+
logger.info('[Kafka] Consumer connected successfully');
|
|
23
|
+
isConnected = true;
|
|
24
|
+
|
|
25
|
+
// Auto-register WelcomeEmailConsumer if it exists
|
|
26
|
+
try {
|
|
27
|
+
const WelcomeEmailConsumer = require('<% if (architecture === "Clean Architecture") { %>../../interfaces/messaging/consumers/instances/welcomeEmailConsumer<% } else { %>../messaging/consumers/instances/welcomeEmailConsumer<% } %>');
|
|
28
|
+
const welcomeConsumer = new WelcomeEmailConsumer();
|
|
29
|
+
await consumer.subscribe({ topic: welcomeConsumer.topic, fromBeginning: true });
|
|
30
|
+
logger.info(`[Kafka] Registered consumer for topic: ${welcomeConsumer.topic}`);
|
|
31
|
+
|
|
32
|
+
await consumer.run({
|
|
33
|
+
eachMessage: async (payload) => welcomeConsumer.onMessage(payload),
|
|
34
|
+
});
|
|
35
|
+
} catch (error) {
|
|
36
|
+
// Fallback or no consumers found
|
|
37
|
+
await consumer.subscribe({ topic: 'user-topic', fromBeginning: true });
|
|
38
|
+
await consumer.run({
|
|
39
|
+
eachMessage: async ({ message }) => {
|
|
40
|
+
logger.info({ value: message.value.toString() });
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return; // Success
|
|
45
|
+
} catch (error) {
|
|
46
|
+
attempt++;
|
|
47
|
+
logger.error(`[Kafka] Connection attempt ${attempt} failed:`, error.message);
|
|
48
|
+
if (attempt >= retries) {
|
|
49
|
+
throw error; // Rethrow after final attempt
|
|
50
|
+
}
|
|
51
|
+
await new Promise(res => setTimeout(res, 3000)); // Wait 3s between retries
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
})();
|
|
55
|
+
|
|
56
|
+
return connectionPromise;
|
|
22
57
|
};
|
|
23
58
|
|
|
24
59
|
const sendMessage = async (topic, message) => {
|
|
25
|
-
if (
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
]
|
|
31
|
-
}
|
|
60
|
+
if (connectionPromise) {
|
|
61
|
+
await connectionPromise;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!isConnected) {
|
|
65
|
+
throw new Error('[Kafka] Producer not connected. Check logs for connection errors.');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
await producer.send({
|
|
70
|
+
topic,
|
|
71
|
+
messages: [{ value: message }],
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const parsed = JSON.parse(message);
|
|
76
|
+
logger.info(`[Kafka] Producer: Sent ${parsed.action} event for '${parsed.payload?.email || 'unknown'}'`);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
logger.info(`[Kafka] Producer: Sent message to ${topic}`, error);
|
|
79
|
+
}
|
|
80
|
+
} catch (error) {
|
|
81
|
+
logger.error(`[Kafka] Failed to send message to ${topic}:`, error.message);
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
32
84
|
};
|
|
33
85
|
|
|
34
86
|
const disconnectKafka = async () => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
let kafka;
|
|
2
2
|
let connectKafka, sendMessage, disconnectKafka;
|
|
3
3
|
|
|
4
|
-
jest.mock('
|
|
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('
|
|
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
|
-
|
|
27
|
-
(
|
|
28
|
-
|
|
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
|
|
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
|
|
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
|
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { BaseConsumer } from '<% if (architecture === "Clean Architecture") { %>@/interfaces/messaging/baseConsumer<% } else { %>@/messaging/baseConsumer<% } %>';
|
|
2
|
+
import logger from '<%= loggerPath %>';
|
|
3
|
+
|
|
4
|
+
jest.mock('<%= loggerPath %>');
|
|
5
|
+
|
|
6
|
+
class TestConsumer extends BaseConsumer {
|
|
7
|
+
constructor() {
|
|
8
|
+
super();
|
|
9
|
+
}
|
|
10
|
+
get topic() { return 'test-topic'; }
|
|
11
|
+
get groupId() { return 'test-group'; }
|
|
12
|
+
async handle(data: any) {
|
|
13
|
+
logger.info('Handled', data);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('BaseConsumer', () => {
|
|
18
|
+
let consumer: TestConsumer;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
jest.clearAllMocks();
|
|
22
|
+
consumer = new TestConsumer();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should throw error if instantiated directly', () => {
|
|
26
|
+
// @ts-expect-error: Abstract class cannot be instantiated
|
|
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 } as any);
|
|
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 } as any);
|
|
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 } as any);
|
|
48
|
+
expect(logger.info).not.toHaveBeenCalled();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { EachMessagePayload } from 'kafkajs';
|
|
2
|
+
import logger from '<%= loggerPath %>';
|
|
3
|
+
|
|
4
|
+
export abstract class BaseConsumer {
|
|
5
|
+
abstract topic: string;
|
|
6
|
+
abstract groupId: string;
|
|
7
|
+
|
|
8
|
+
public constructor() {
|
|
9
|
+
if (new.target === BaseConsumer) {
|
|
10
|
+
throw new Error("Abstract class 'BaseConsumer' cannot be instantiated.");
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async onMessage({ message }: EachMessagePayload) {
|
|
15
|
+
try {
|
|
16
|
+
const rawValue = message.value?.toString();
|
|
17
|
+
if (!rawValue) return;
|
|
18
|
+
|
|
19
|
+
const data = JSON.parse(rawValue);
|
|
20
|
+
await this.handle(data);
|
|
21
|
+
} catch (error) {
|
|
22
|
+
logger.error(`[Kafka] Error processing message on topic ${this.topic}:`, error);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
abstract handle(data: unknown): Promise<void>;
|
|
27
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { UserEventSchema } from '<% 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 validate a correct UPDATE_USER event', () => {
|
|
17
|
+
const validEvent = {
|
|
18
|
+
action: 'UPDATE_USER',
|
|
19
|
+
payload: {
|
|
20
|
+
id: 'abc-123',
|
|
21
|
+
email: 'test@example.com'
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
const result = UserEventSchema.safeParse(validEvent);
|
|
25
|
+
expect(result.success).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should fail validation for invalid email', () => {
|
|
29
|
+
const invalidEvent = {
|
|
30
|
+
action: 'USER_CREATED',
|
|
31
|
+
payload: {
|
|
32
|
+
id: 1,
|
|
33
|
+
email: 'invalid-email'
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
const result = UserEventSchema.safeParse(invalidEvent);
|
|
37
|
+
expect(result.success).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should fail validation for invalid action', () => {
|
|
41
|
+
const invalidEvent = {
|
|
42
|
+
action: 'INVALID_ACTION',
|
|
43
|
+
payload: {
|
|
44
|
+
id: 1,
|
|
45
|
+
email: 'test@example.com'
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
const result = UserEventSchema.safeParse(invalidEvent);
|
|
49
|
+
expect(result.success).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export 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
|
+
export type UserEvent = z.infer<typeof UserEventSchema>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { WelcomeEmailConsumer } from '<% if (architecture === "Clean Architecture") { %>@/interfaces/messaging/consumers/instances/welcomeEmailConsumer<% } else { %>@/messaging/consumers/instances/welcomeEmailConsumer<% } %>';
|
|
2
|
+
import logger from '<%= loggerPath %>';
|
|
3
|
+
|
|
4
|
+
jest.mock('<%= loggerPath %>');
|
|
5
|
+
|
|
6
|
+
describe('WelcomeEmailConsumer', () => {
|
|
7
|
+
let consumer: WelcomeEmailConsumer;
|
|
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
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { BaseConsumer } from '<% if (architecture === "Clean Architecture") { %>@/interfaces/messaging/baseConsumer<% } else { %>@/messaging/baseConsumer<% } %>';
|
|
2
|
+
import logger from '<%= loggerPath %>';
|
|
3
|
+
import { UserEventSchema } from '<% if (architecture === "Clean Architecture") { %>@/interfaces/messaging/schemas/userEventSchema<% } else { %>@/messaging/schemas/userEventSchema<% } %>';
|
|
4
|
+
|
|
5
|
+
export class WelcomeEmailConsumer extends BaseConsumer {
|
|
6
|
+
topic = 'user-topic';
|
|
7
|
+
groupId = 'welcome-email-group';
|
|
8
|
+
|
|
9
|
+
async handle(data: unknown) {
|
|
10
|
+
const result = UserEventSchema.safeParse(data);
|
|
11
|
+
|
|
12
|
+
if (!result.success) {
|
|
13
|
+
logger.error('[Kafka] Invalid user event data:', result.error.format());
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const { action, payload } = result.data;
|
|
18
|
+
|
|
19
|
+
if (action === 'USER_CREATED') {
|
|
20
|
+
logger.info(`[Kafka] Consumer: Received USER_CREATED.`);
|
|
21
|
+
logger.info(`[Kafka] Consumer: 📧 Sending welcome email to '${payload.email}'... Done!`);
|
|
22
|
+
// In a real app, you would call an EmailService here
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -34,13 +34,14 @@ describe('KafkaService', () => {
|
|
|
34
34
|
|
|
35
35
|
expect(producer.connect).toHaveBeenCalled();
|
|
36
36
|
expect(consumer.connect).toHaveBeenCalled();
|
|
37
|
-
expect(consumer.subscribe).toHaveBeenCalledWith({ topic: '
|
|
37
|
+
expect(consumer.subscribe).toHaveBeenCalledWith(expect.objectContaining({ topic: 'user-topic', fromBeginning: true }));
|
|
38
38
|
expect(consumer.run).toHaveBeenCalled();
|
|
39
39
|
});
|
|
40
40
|
|
|
41
41
|
it('should send a message', async () => {
|
|
42
|
+
await kafkaService.connect();
|
|
42
43
|
const topic = 'test-topic';
|
|
43
|
-
const message = 'test
|
|
44
|
+
const message = JSON.stringify({ action: 'TEST', payload: { email: 'test@example.com' } });
|
|
44
45
|
await kafkaService.sendMessage(topic, message);
|
|
45
46
|
const producer = (kafka.producer as jest.Mock).mock.results[0].value;
|
|
46
47
|
|
|
@@ -50,6 +51,25 @@ describe('KafkaService', () => {
|
|
|
50
51
|
});
|
|
51
52
|
});
|
|
52
53
|
|
|
54
|
+
it('should retry connection on failure', async () => {
|
|
55
|
+
const producer = (kafka.producer as jest.Mock).mock.results[0].value;
|
|
56
|
+
producer.connect
|
|
57
|
+
.mockRejectedValueOnce(new Error('Connection failed'))
|
|
58
|
+
.mockResolvedValueOnce(undefined);
|
|
59
|
+
|
|
60
|
+
jest.useFakeTimers();
|
|
61
|
+
const connectPromise = kafkaService.connect(2);
|
|
62
|
+
|
|
63
|
+
await jest.advanceTimersByTimeAsync(10000);
|
|
64
|
+
await connectPromise;
|
|
65
|
+
|
|
66
|
+
expect(producer.connect).toHaveBeenCalledTimes(2);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should throw error if producer not connected', async () => {
|
|
70
|
+
await expect(kafkaService.sendMessage('topic', 'msg')).rejects.toThrow('[Kafka] Producer not connected');
|
|
71
|
+
});
|
|
72
|
+
|
|
53
73
|
it('should disconnect producer and consumer', async () => {
|
|
54
74
|
await kafkaService.disconnect();
|
|
55
75
|
const producer = (kafka.producer as jest.Mock).mock.results[0].value;
|
|
@@ -5,33 +5,92 @@ import logger from '<%= loggerPath %>';
|
|
|
5
5
|
export class KafkaService {
|
|
6
6
|
private producer: Producer;
|
|
7
7
|
private consumer: Consumer;
|
|
8
|
+
private isConnected = false;
|
|
9
|
+
private connectionPromise: Promise<void> | null = null;
|
|
8
10
|
|
|
9
11
|
constructor() {
|
|
10
12
|
this.producer = kafka.producer();
|
|
11
13
|
this.consumer = kafka.consumer({ groupId: 'test-group' });
|
|
12
14
|
}
|
|
13
15
|
|
|
14
|
-
async connect() {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
16
|
+
async connect(retries = 10) {
|
|
17
|
+
if (this.connectionPromise) return this.connectionPromise;
|
|
18
|
+
|
|
19
|
+
this.connectionPromise = (async () => {
|
|
20
|
+
let attempt = 0;
|
|
21
|
+
while (attempt < retries) {
|
|
22
|
+
try {
|
|
23
|
+
await this.producer.connect();
|
|
24
|
+
await this.consumer.connect();
|
|
25
|
+
logger.info('[Kafka] Producer connected successfully');
|
|
26
|
+
logger.info('[Kafka] Consumer connected successfully');
|
|
27
|
+
this.isConnected = true;
|
|
28
|
+
|
|
29
|
+
<%_ if (language === 'TypeScript') { -%>
|
|
30
|
+
// Auto-register WelcomeEmailConsumer if it exists
|
|
31
|
+
try {
|
|
32
|
+
const { WelcomeEmailConsumer } = await import('<% if (architecture === "Clean Architecture") { %>@/interfaces/messaging/consumers/instances/welcomeEmailConsumer<% } else { %>@/messaging/consumers/instances/welcomeEmailConsumer<% } %>');
|
|
33
|
+
const welcomeConsumer = new WelcomeEmailConsumer();
|
|
34
|
+
await this.consumer.subscribe({ topic: welcomeConsumer.topic, fromBeginning: true });
|
|
35
|
+
logger.info(`[Kafka] Registered consumer for topic: ${welcomeConsumer.topic}`);
|
|
36
|
+
|
|
37
|
+
await this.consumer.run({
|
|
38
|
+
eachMessage: async (payload) => welcomeConsumer.onMessage(payload),
|
|
39
|
+
});
|
|
40
|
+
} catch (e) {
|
|
41
|
+
// Fallback or no consumers found
|
|
42
|
+
logger.warn(`[Kafka] Could not load WelcomeEmailConsumer, using fallback: ${(e as Error).message}`);
|
|
43
|
+
await this.consumer.subscribe({ topic: 'user-topic', fromBeginning: true });
|
|
44
|
+
await this.consumer.run({
|
|
45
|
+
eachMessage: async ({ message }: EachMessagePayload) => {
|
|
46
|
+
logger.info(`[Kafka] Consumer: Received message on user-topic: ${message.value?.toString()}`);
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
<%_ } else { _%>
|
|
51
|
+
await this.consumer.subscribe({ topic: 'test-topic', fromBeginning: true });
|
|
52
|
+
await this.consumer.run({
|
|
53
|
+
eachMessage: async ({ message }: EachMessagePayload) => {
|
|
54
|
+
logger.info({ value: message.value?.toString() });
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
<%_ } _%>
|
|
58
|
+
return;
|
|
59
|
+
} catch (error) {
|
|
60
|
+
attempt++;
|
|
61
|
+
logger.error(`[Kafka] Connection attempt ${attempt} failed:`, (error as Error).message);
|
|
62
|
+
if (attempt >= retries) {
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
await new Promise(res => setTimeout(res, 3000));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
})();
|
|
69
|
+
|
|
70
|
+
return this.connectionPromise;
|
|
26
71
|
}
|
|
27
72
|
|
|
28
73
|
async sendMessage(topic: string, message: string) {
|
|
74
|
+
if (this.connectionPromise) {
|
|
75
|
+
await this.connectionPromise;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!this.isConnected) {
|
|
79
|
+
throw new Error('[Kafka] Producer not connected. Check logs for connection errors.');
|
|
80
|
+
}
|
|
81
|
+
|
|
29
82
|
await this.producer.send({
|
|
30
83
|
topic,
|
|
31
84
|
messages: [
|
|
32
85
|
{ value: message },
|
|
33
86
|
],
|
|
34
87
|
});
|
|
88
|
+
try {
|
|
89
|
+
const parsed = JSON.parse(message);
|
|
90
|
+
logger.info(`[Kafka] Producer: Sent ${parsed.action} event for '${parsed.payload?.email || 'unknown'}'`);
|
|
91
|
+
} catch {
|
|
92
|
+
logger.info(`[Kafka] Producer: Sent message to ${topic}`);
|
|
93
|
+
}
|
|
35
94
|
}
|
|
36
95
|
|
|
37
96
|
async disconnect() {
|
|
@@ -40,3 +99,4 @@ export class KafkaService {
|
|
|
40
99
|
}
|
|
41
100
|
}
|
|
42
101
|
|
|
102
|
+
export const kafkaService = new KafkaService();
|