nodejs-quickstart-structure 2.1.2 → 2.2.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +12 -17
  3. package/bin/index.js +1 -0
  4. package/lib/generator.js +1 -1
  5. package/lib/modules/app-setup.js +16 -0
  6. package/lib/modules/auth-setup.js +46 -4
  7. package/lib/prompts.js +44 -4
  8. package/package.json +1 -1
  9. package/templates/clean-architecture/js/src/infrastructure/config/env.js.ejs +12 -2
  10. package/templates/clean-architecture/js/src/infrastructure/repositories/UserRepository.js.ejs +27 -0
  11. package/templates/clean-architecture/js/src/infrastructure/repositories/UserRepository.spec.js.ejs +24 -0
  12. package/templates/clean-architecture/js/src/infrastructure/webserver/server.js.ejs +3 -1
  13. package/templates/clean-architecture/ts/src/config/env.ts.ejs +12 -2
  14. package/templates/clean-architecture/ts/src/domain/user.ts.ejs +14 -0
  15. package/templates/clean-architecture/ts/src/infrastructure/repositories/UserRepository.spec.ts.ejs +24 -0
  16. package/templates/clean-architecture/ts/src/infrastructure/repositories/userRepository.ts.ejs +43 -45
  17. package/templates/clean-architecture/ts/src/interfaces/graphql/resolvers/user.resolvers.ts.ejs +5 -5
  18. package/templates/common/.env.example.ejs +10 -0
  19. package/templates/common/README.md.ejs +65 -14
  20. package/templates/common/auth/js/controllers/authController.js.ejs +326 -13
  21. package/templates/common/auth/js/controllers/authController.spec.js.ejs +237 -51
  22. package/templates/common/auth/js/middleware/authMiddleware.js.ejs +10 -6
  23. package/templates/common/auth/js/routes/authRoutes.js.ejs +11 -0
  24. package/templates/common/auth/js/services/jwtService.js.ejs +3 -3
  25. package/templates/common/auth/js/services/jwtService.spec.js.ejs +30 -0
  26. package/templates/common/auth/js/services/socialAuthService.js.ejs +175 -0
  27. package/templates/common/auth/js/services/socialAuthService.spec.js.ejs +194 -0
  28. package/templates/common/auth/js/usecases/SocialLoginUseCase.js.ejs +114 -0
  29. package/templates/common/auth/js/usecases/SocialLoginUseCase.spec.js.ejs +143 -0
  30. package/templates/common/auth/ts/controllers/authController.spec.ts.ejs +344 -64
  31. package/templates/common/auth/ts/controllers/authController.ts.ejs +341 -9
  32. package/templates/common/auth/ts/middleware/authMiddleware.ts.ejs +10 -6
  33. package/templates/common/auth/ts/routes/authRoutes.ts.ejs +11 -0
  34. package/templates/common/auth/ts/services/jwtService.spec.ts.ejs +18 -0
  35. package/templates/common/auth/ts/services/jwtService.ts.ejs +3 -3
  36. package/templates/common/auth/ts/services/socialAuthService.spec.ts.ejs +187 -0
  37. package/templates/common/auth/ts/services/socialAuthService.ts.ejs +189 -0
  38. package/templates/common/auth/ts/usecases/SocialLoginUseCase.spec.ts.ejs +143 -0
  39. package/templates/common/auth/ts/usecases/SocialLoginUseCase.ts.ejs +117 -0
  40. package/templates/common/database/js/models/User.js.ejs +13 -5
  41. package/templates/common/database/js/models/User.js.mongoose.ejs +15 -1
  42. package/templates/common/database/ts/models/User.ts.ejs +23 -7
  43. package/templates/common/database/ts/models/User.ts.mongoose.ejs +18 -2
  44. package/templates/common/docker-compose.yml.ejs +21 -0
  45. package/templates/common/ecosystem.config.js.ejs +10 -0
  46. package/templates/common/jest.config.js.ejs +1 -1
  47. package/templates/common/kafka/js/services/kafkaService.js.ejs +1 -1
  48. package/templates/common/kafka/ts/services/kafkaService.ts.ejs +1 -1
  49. package/templates/common/package.json.ejs +2 -0
  50. package/templates/common/src/tests/e2e/e2e.users.test.js.ejs +13 -1
  51. package/templates/common/src/tests/e2e/e2e.users.test.ts.ejs +13 -1
  52. package/templates/common/swagger.yml.ejs +62 -3
  53. package/templates/common/views/ejs/login.ejs.ejs +84 -0
  54. package/templates/common/views/ejs/signup.ejs.ejs +84 -0
  55. package/templates/common/views/pug/login.pug.ejs +78 -0
  56. package/templates/common/views/pug/signup.pug.ejs +78 -0
  57. package/templates/db/mysql/V1__Initial_Setup.sql.ejs +3 -1
  58. package/templates/db/postgres/V1__Initial_Setup.sql.ejs +3 -1
  59. package/templates/mvc/js/src/config/env.js.ejs +12 -2
  60. package/templates/mvc/js/src/controllers/userController.js.ejs +1 -1
  61. package/templates/mvc/js/src/graphql/resolvers/user.resolvers.js.ejs +4 -3
  62. package/templates/mvc/ts/src/config/env.ts.ejs +12 -2
  63. package/templates/mvc/ts/src/controllers/userController.ts.ejs +1 -0
  64. package/templates/mvc/ts/src/graphql/resolvers/user.resolvers.ts.ejs +5 -5
  65. package/templates/mvc/ts/src/index.ts.ejs +2 -1
  66. package/templates/clean-architecture/ts/src/domain/user.ts +0 -9
@@ -0,0 +1,194 @@
1
+ const axios = require('axios');
2
+ <% if (architecture === 'MVC') { -%>
3
+ const { SocialAuthService } = require('@/services/socialAuthService');
4
+ const logger = require('@/utils/logger');
5
+ <% } else { -%>
6
+ const logger = require('@/infrastructure/log/logger');
7
+ <% } -%>
8
+
9
+ jest.mock('axios');
10
+ jest.mock('<% if (architecture === "MVC") { %>@/utils/logger<% } else { %>@/infrastructure/log/logger<% } %>', () => ({
11
+ error: jest.fn(),
12
+ info: jest.fn(),
13
+ warn: jest.fn(),
14
+ }));
15
+
16
+ // Mock environment variables for testing
17
+ process.env.GOOGLE_CLIENT_ID = 'test-google-id';
18
+ process.env.GOOGLE_CLIENT_SECRET = 'test-google-secret';
19
+ process.env.GITHUB_CLIENT_ID = 'test-github-id';
20
+ process.env.GITHUB_CLIENT_SECRET = 'test-github-secret';
21
+
22
+ describe('SocialAuthService', () => {
23
+ afterEach(() => {
24
+ jest.clearAllMocks();
25
+ });
26
+
27
+ <% if (socialAuth.includes('Google')) { -%>
28
+ describe('Google Provider', () => {
29
+ it('should exchange code for profile', async () => {
30
+ const mockTokenResponse = { data: { access_token: 'mock_access_token' } };
31
+ const mockProfileResponse = {
32
+ data: {
33
+ id: '123',
34
+ email: 'test@gmail.com',
35
+ name: 'Test User',
36
+ picture: 'http://pic.com/123.jpg'
37
+ }
38
+ };
39
+
40
+ axios.post.mockResolvedValue(mockTokenResponse);
41
+ axios.get.mockResolvedValue(mockProfileResponse);
42
+
43
+ let profile;
44
+ <% if (architecture === 'Clean Architecture') { %>
45
+ const { GoogleProvider } = require('@/infrastructure/auth/socialAuthService');
46
+ const provider = new GoogleProvider();
47
+ profile = await provider.getProfile('test_code', 'http://localhost/callback');
48
+ <% } else { %>
49
+ profile = await SocialAuthService.getGoogleProfile('test_code', 'http://localhost/callback');
50
+ <% } %>
51
+
52
+ expect(axios.post).toHaveBeenCalled();
53
+ expect(axios.get).toHaveBeenCalledWith('https://www.googleapis.com/oauth2/v2/userinfo', {
54
+ headers: { Authorization: 'Bearer mock_access_token' },
55
+ });
56
+ expect(profile.email).toBe('test@gmail.com');
57
+ expect(profile.name).toBe('Test User');
58
+ });
59
+
60
+ it('should throw error if token exchange fails', async () => {
61
+ axios.post.mockRejectedValue(new Error('Network error'));
62
+ <%_ if (architecture === 'Clean Architecture') { _%>
63
+ const { GoogleProvider } = require('@/infrastructure/auth/socialAuthService');
64
+ const provider = new GoogleProvider();
65
+ await expect(provider.getProfile('test_code', 'url')).rejects.toThrow('Failed to authenticate with Google');
66
+ <%_ } else { _%>
67
+ await expect(SocialAuthService.getGoogleProfile('test_code', 'url')).rejects.toThrow('Failed to authenticate with Google');
68
+ <%_ } _%>
69
+ });
70
+
71
+ it('should handle Axios errors', async () => {
72
+ const axiosError = new Error('Request failed');
73
+ axiosError.isAxiosError = true;
74
+ axiosError.response = { data: { message: 'OAuth Error' } };
75
+ axios.isAxiosError.mockReturnValue(true);
76
+ axios.post.mockRejectedValue(axiosError);
77
+
78
+ <%_ if (architecture === 'Clean Architecture') { _%>
79
+ const { GoogleProvider } = require('@/infrastructure/auth/socialAuthService');
80
+ const provider = new GoogleProvider();
81
+ await expect(provider.getProfile('test_code', 'url')).rejects.toThrow('Failed to authenticate with Google');
82
+ <%_ } else { _%>
83
+ await expect(SocialAuthService.getGoogleProfile('test_code', 'url')).rejects.toThrow('Failed to authenticate with Google');
84
+ <%_ } _%>
85
+ });
86
+
87
+ it('should log hint on invalid_grant error', async () => {
88
+ const axiosError = new Error('Request failed');
89
+ axiosError.isAxiosError = true;
90
+ axiosError.response = { data: { error: 'invalid_grant' } };
91
+ axios.isAxiosError.mockReturnValue(true);
92
+ axios.post.mockRejectedValue(axiosError);
93
+
94
+ <%_ if (architecture === 'MVC') { _%>
95
+ await expect(SocialAuthService.getGoogleProfile('test_code', 'url')).rejects.toThrow('Failed to authenticate with Google');
96
+ expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('HINT'));
97
+ <%_ } else { _%>
98
+ const { GoogleProvider } = require('@/infrastructure/auth/socialAuthService');
99
+ const provider = new GoogleProvider();
100
+ await expect(provider.getProfile('test_code', 'url')).rejects.toThrow('Failed to authenticate with Google');
101
+ <%_ } _%>
102
+ });
103
+ });
104
+ <% } -%>
105
+
106
+ <% if (socialAuth.includes('GitHub')) { -%>
107
+ describe('GitHub Provider', () => {
108
+ it('should exchange code for profile', async () => {
109
+ const mockTokenResponse = { data: { access_token: 'mock_access_token' } };
110
+ const mockProfileResponse = {
111
+ data: { id: 456, login: 'testuser', name: 'Test Github User' }
112
+ };
113
+ const mockEmailsResponse = {
114
+ data: [{ email: 'github@test.com', primary: true }]
115
+ };
116
+
117
+ axios.post.mockResolvedValue(mockTokenResponse);
118
+ axios.get
119
+ .mockResolvedValueOnce(mockProfileResponse)
120
+ .mockResolvedValueOnce(mockEmailsResponse);
121
+
122
+ let profile;
123
+ <% if (architecture === 'Clean Architecture') { %>
124
+ const { GitHubProvider } = require('@/infrastructure/auth/socialAuthService');
125
+ const provider = new GitHubProvider();
126
+ profile = await provider.getProfile('test_code');
127
+ <% } else { %>
128
+ profile = await SocialAuthService.getGithubProfile('test_code');
129
+ <% } %>
130
+
131
+ expect(axios.post).toHaveBeenCalled();
132
+ expect(axios.get).toHaveBeenCalledTimes(2);
133
+ expect(profile.email).toBe('github@test.com');
134
+ expect(profile.id).toBe('456');
135
+ });
136
+
137
+ it('should throw error if token exchange fails', async () => {
138
+ axios.post.mockRejectedValue(new Error('Network error'));
139
+ <%_ if (architecture === 'Clean Architecture') { _%>
140
+ const { GitHubProvider } = require('@/infrastructure/auth/socialAuthService');
141
+ const provider = new GitHubProvider();
142
+ await expect(provider.getProfile('test_code')).rejects.toThrow('Failed to authenticate with GitHub');
143
+ <%_ } else { _%>
144
+ await expect(SocialAuthService.getGithubProfile('test_code')).rejects.toThrow('Failed to authenticate with GitHub');
145
+ <%_ } _%>
146
+ });
147
+
148
+ it('should handle Axios errors', async () => {
149
+ const axiosError = new Error('Request failed');
150
+ axiosError.isAxiosError = true;
151
+ axiosError.response = { data: { message: 'OAuth Error' } };
152
+ axios.isAxiosError.mockReturnValue(true);
153
+ axios.post.mockRejectedValue(axiosError);
154
+
155
+ <%_ if (architecture === 'Clean Architecture') { _%>
156
+ const { GitHubProvider } = require('@/infrastructure/auth/socialAuthService');
157
+ const provider = new GitHubProvider();
158
+ await expect(provider.getProfile('test_code')).rejects.toThrow('Failed to authenticate with GitHub');
159
+ <%_ } else { _%>
160
+ await expect(SocialAuthService.getGithubProfile('test_code')).rejects.toThrow('Failed to authenticate with GitHub');
161
+ <%_ } _%>
162
+ });
163
+
164
+ it('should fallback to first email if primary not found', async () => {
165
+ const mockTokenResponse = { data: { access_token: 'mock_at' } };
166
+ axios.post.mockResolvedValue(mockTokenResponse);
167
+ axios.get
168
+ .mockResolvedValueOnce({ data: { id: 789, login: 'test' } })
169
+ .mockResolvedValueOnce({ data: [{ email: 'secondary@test.com', primary: false }] });
170
+
171
+ let profile;
172
+ <% if (architecture === 'Clean Architecture') { %>
173
+ const { GitHubProvider } = require('@/infrastructure/auth/socialAuthService');
174
+ const provider = new GitHubProvider();
175
+ profile = await provider.getProfile('code');
176
+ <% } else { %>
177
+ profile = await SocialAuthService.getGithubProfile('code');
178
+ <% } %>
179
+ expect(profile.email).toBe('secondary@test.com');
180
+ });
181
+
182
+ it('should throw error if no access token returned', async () => {
183
+ axios.post.mockResolvedValue({ data: {} });
184
+ <% if (architecture === 'Clean Architecture') { %>
185
+ const { GitHubProvider } = require('@/infrastructure/auth/socialAuthService');
186
+ const provider = new GitHubProvider();
187
+ await expect(provider.getProfile('code')).rejects.toThrow('Failed to authenticate with GitHub');
188
+ <% } else { %>
189
+ await expect(SocialAuthService.getGithubProfile('code')).rejects.toThrow('Failed to authenticate with GitHub');
190
+ <% } %>
191
+ });
192
+ });
193
+ <% } -%>
194
+ });
@@ -0,0 +1,114 @@
1
+ const JwtService = require('<% if (architecture === "MVC") { %>../services/jwtService<% } else { %>../../infrastructure/auth/jwtService<% } %>');
2
+ <%_ if (architecture === 'Clean Architecture') { _%>
3
+ const User = require('../../domain/models/User');
4
+ <%_ } else { _%>
5
+ const User = require('<% if (architecture === "MVC") { %>../models/User<% } else { %>../../infrastructure/database/models/User<% } %>');
6
+ <%_ } _%>
7
+
8
+ class SocialLoginUseCase {
9
+ constructor(provider<% if (architecture === 'Clean Architecture') { %>, userRepository<% } %>) {
10
+ this.provider = provider;
11
+ <% if (architecture === 'Clean Architecture') { %>this.userRepository = userRepository;<% } %>
12
+ }
13
+
14
+ async execute(code, redirectUri) {
15
+ const profile = await this.provider.getProfile(code, redirectUri);
16
+
17
+ if (!profile || !profile.email) {
18
+ throw new Error('No email associated with this social account');
19
+ }
20
+
21
+ // 1. Find or create user
22
+ <% if (architecture === 'Clean Architecture') { -%>
23
+ let user = await this.userRepository.findByEmail(profile.email);
24
+
25
+ if (!user) {
26
+ user = new User(
27
+ null,
28
+ profile.name,
29
+ profile.email,
30
+ null,
31
+ <%_ if (socialAuth.includes('Google')) { _%>
32
+ this.provider.name === 'Google' ? profile.id : null,
33
+ <%_ } _%>
34
+ <%_ if (socialAuth.includes('GitHub')) { _%>
35
+ this.provider.name === 'GitHub' ? profile.id : null,
36
+ <%_ } _%>
37
+ );
38
+ user = await this.userRepository.save(user);
39
+ } else {
40
+ // Link social ID if not already linked
41
+ let updated = false;
42
+ <%_ if (socialAuth.includes('Google')) { _%>
43
+ if (this.provider.name === 'Google' && !user.googleId) {
44
+ user.googleId = profile.id;
45
+ updated = true;
46
+ }
47
+ <%_ } _%>
48
+ <%_ if (socialAuth.includes('GitHub')) { _%>
49
+ if (this.provider.name === 'GitHub' && !user.githubId) {
50
+ user.githubId = profile.id;
51
+ updated = true;
52
+ }
53
+ <%_ } _%>
54
+ if (updated) {
55
+ await this.userRepository.update(user.id, user);
56
+ }
57
+ }
58
+ <% } else { -%>
59
+ <% if (database === 'MongoDB' || database === 'None') { %>
60
+ let user = await User.findOne({ email: profile.email });
61
+ <% } else { %>
62
+ let user = await User.findOne({ where: { email: profile.email } });
63
+ <% } %>
64
+
65
+ if (!user) {
66
+ user = await User.create({
67
+ email: profile.email,
68
+ name: profile.name,
69
+ password: null, // Social login users don't have passwords
70
+ <%_ if (socialAuth.includes('Google')) { _%>
71
+ googleId: this.provider.name === 'Google' ? profile.id : null,
72
+ <%_ } _%>
73
+ <%_ if (socialAuth.includes('GitHub')) { _%>
74
+ githubId: this.provider.name === 'GitHub' ? profile.id : null,
75
+ <%_ } _%>
76
+ });
77
+ } else {
78
+ // Link social ID if not already linked
79
+ let updated = false;
80
+ <%_ if (socialAuth.includes('Google')) { _%>
81
+ if (this.provider.name === 'Google' && !user.googleId) {
82
+ user.googleId = profile.id;
83
+ updated = true;
84
+ }
85
+ <%_ } _%>
86
+ <%_ if (socialAuth.includes('GitHub')) { _%>
87
+ if (this.provider.name === 'GitHub' && !user.githubId) {
88
+ user.githubId = profile.id;
89
+ updated = true;
90
+ }
91
+ <%_ } _%>
92
+ if (updated) {
93
+ <%_ if (database === 'MongoDB') { _%>
94
+ await user.save();
95
+ <%_ } else if (database === 'None') { _%>
96
+ await User.update(user.id, { googleId: user.googleId, githubId: user.githubId });
97
+ <%_ } else { _%>
98
+ await user.save();
99
+ <%_ } _%>
100
+ }
101
+ }
102
+ <% } -%>
103
+
104
+ // 2. Generate tokens (jti is handled inside JwtService)
105
+ const userId = String(user.id || user._id);
106
+ const payload = { id: userId, email: user.email };
107
+ const accessToken = JwtService.generateToken(payload);
108
+ const refreshToken = JwtService.generateRefreshToken(payload);
109
+
110
+ return { user, accessToken, refreshToken };
111
+ }
112
+ }
113
+
114
+ module.exports = { SocialLoginUseCase };
@@ -0,0 +1,143 @@
1
+ jest.mock('@/infrastructure/auth/jwtService');
2
+ <% if (architecture === 'Clean Architecture') { -%>
3
+ jest.mock('@/infrastructure/repositories/UserRepository', () => {
4
+ return jest.fn().mockImplementation(() => ({
5
+ findByEmail: jest.fn(),
6
+ save: jest.fn(),
7
+ update: jest.fn(),
8
+ }));
9
+ });
10
+ const UserRepository = require('@/infrastructure/repositories/UserRepository');
11
+ <% } else { -%>
12
+ jest.mock('@/infrastructure/database/models/User', () => ({
13
+ findOne: jest.fn(),
14
+ create: jest.fn(),
15
+ update: jest.fn(),
16
+ }));
17
+ const User = require('@/infrastructure/database/models/User');
18
+ <% } -%>
19
+
20
+ const { SocialLoginUseCase } = require('@/usecases/auth/socialLoginUseCase');
21
+ const JwtService = require('@/infrastructure/auth/jwtService');
22
+
23
+ describe('SocialLoginUseCase', () => {
24
+ let useCase;
25
+ let mockProvider;
26
+ <%_ if (architecture === 'Clean Architecture') { _%>
27
+ let mockRepo;
28
+ <%_ } _%>
29
+
30
+ beforeEach(() => {
31
+ mockProvider = {
32
+ name: 'Google',
33
+ getProfile: jest.fn(),
34
+ };
35
+ <%_ if (architecture === 'Clean Architecture') { _%>
36
+ mockRepo = new UserRepository();
37
+ useCase = new SocialLoginUseCase(mockProvider, mockRepo);
38
+ <%_ } else { _%>
39
+ useCase = new SocialLoginUseCase(mockProvider);
40
+ <%_ } _%>
41
+ jest.clearAllMocks();
42
+ });
43
+
44
+ it('should find existing user and generate tokens', async () => {
45
+ const mockProfile = { id: 'google-123', email: 'test@test.com', name: 'Test User' };
46
+ const mockUser = { id: '1', email: 'test@test.com', googleId: null };
47
+
48
+ mockProvider.getProfile.mockResolvedValue(mockProfile);
49
+ <%_ if (architecture === 'Clean Architecture') { _%>
50
+ mockRepo.findByEmail.mockResolvedValue(mockUser);
51
+ <%_ } else { _%>
52
+ User.findOne.mockResolvedValue(mockUser);
53
+ <%_ } _%>
54
+ JwtService.generateToken.mockReturnValue('access-token');
55
+ JwtService.generateRefreshToken.mockReturnValue('refresh-token');
56
+
57
+ const result = await useCase.execute('test-code');
58
+
59
+ <%_ if (architecture === 'Clean Architecture') { _%>
60
+ expect(mockRepo.findByEmail).toHaveBeenCalled();
61
+ <%_ } else { _%>
62
+ expect(User.findOne).toHaveBeenCalled();
63
+ <%_ } _%>
64
+ expect(result.accessToken).toBe('access-token');
65
+ });
66
+
67
+ it('should create new user if not exists', async () => {
68
+ const mockProfile = { id: 'google-456', email: 'new@test.com', name: 'New User' };
69
+ const mockUser = { id: '2', email: 'new@test.com' };
70
+
71
+ mockProvider.getProfile.mockResolvedValue(mockProfile);
72
+ <%_ if (architecture === 'Clean Architecture') { _%>
73
+ mockRepo.findByEmail.mockResolvedValue(null);
74
+ mockRepo.save.mockResolvedValue(mockUser);
75
+ <%_ } else { _%>
76
+ User.findOne.mockResolvedValue(null);
77
+ User.create.mockResolvedValue(mockUser);
78
+ <%_ } _%>
79
+ JwtService.generateToken.mockReturnValue('access-token');
80
+ JwtService.generateRefreshToken.mockReturnValue('refresh-token');
81
+
82
+ await useCase.execute('test-code');
83
+
84
+ <%_ if (architecture === 'Clean Architecture') { _%>
85
+ expect(mockRepo.save).toHaveBeenCalled();
86
+ <%_ } else { _%>
87
+ expect(User.create).toHaveBeenCalled();
88
+ <%_ } _%>
89
+ });
90
+
91
+ it('should link GitHub ID if existing user does not have it', async () => {
92
+ mockProvider.name = 'GitHub';
93
+ const mockProfile = { id: 'github-789', email: 'test@test.com', name: 'Test User' };
94
+ const mockUser = { id: '1', email: 'test@test.com', githubId: null };
95
+
96
+ mockProvider.getProfile.mockResolvedValue(mockProfile);
97
+ <%_ if (architecture === 'Clean Architecture') { _%>
98
+ mockRepo.findByEmail.mockResolvedValue(mockUser);
99
+ <%_ } else { _%>
100
+ User.findOne.mockResolvedValue(mockUser);
101
+ <%_ } _%>
102
+ JwtService.generateToken.mockReturnValue('access-token');
103
+ JwtService.generateRefreshToken.mockReturnValue('refresh-token');
104
+
105
+ await useCase.execute('test-code');
106
+
107
+ <%_ if (architecture === 'Clean Architecture') { _%>
108
+ expect(mockRepo.update).toHaveBeenCalledWith('1', expect.objectContaining({ githubId: 'github-789' }));
109
+ <%_ } else { _%>
110
+ <%_ if (database === 'MongoDB') { _%>
111
+ expect(mockUser.save || jest.fn()).toHaveBeenCalled();
112
+ <%_ } else { _%>
113
+ expect(User.update || jest.fn()).toHaveBeenCalled();
114
+ <%_ } _%>
115
+ <%_ } _%>
116
+ });
117
+
118
+ it('should link Google ID if existing user does not have it', async () => {
119
+ mockProvider.name = 'Google';
120
+ const mockProfile = { id: 'google-789', email: 'test@test.com', name: 'Test User' };
121
+ const mockUser = { id: '1', email: 'test@test.com', googleId: null };
122
+
123
+ mockProvider.getProfile.mockResolvedValue(mockProfile);
124
+ <%_ if (architecture === 'Clean Architecture') { _%>
125
+ mockRepo.findByEmail.mockResolvedValue(mockUser);
126
+ <%_ } else { _%>
127
+ User.findOne.mockResolvedValue(mockUser);
128
+ <%_ } _%>
129
+ JwtService.generateToken.mockReturnValue('access-token');
130
+ JwtService.generateRefreshToken.mockReturnValue('refresh-token');
131
+
132
+ await useCase.execute('test-code');
133
+
134
+ <%_ if (architecture === 'Clean Architecture') { _%>
135
+ expect(mockRepo.update).toHaveBeenCalledWith('1', expect.objectContaining({ googleId: 'google-789' }));
136
+ <%_ } _%>
137
+ });
138
+
139
+ it('should throw error if profile has no email', async () => {
140
+ mockProvider.getProfile.mockResolvedValue({ id: '123' }); // No email
141
+ await expect(useCase.execute('test-code')).rejects.toThrow('No email associated with this social account');
142
+ });
143
+ });