@flusys/nestjs-localization 6.0.2 → 6.1.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.
@@ -0,0 +1,278 @@
1
+ import { Test } from '@nestjs/testing';
2
+ import { UtilsService } from '@flusys/nestjs-shared/modules';
3
+ import { ForbiddenException, BadRequestException } from '@nestjs/common';
4
+ import { createMockDataSourceProvider, createMockRepository } from '@test-utils/mocks/repository.mock';
5
+ import { buildMockUser } from '@test-utils/mocks/logged-user.mock';
6
+ import { TranslationKeyService } from './translation-key.service';
7
+ import { LocalizationDataSourceProvider } from './localization-datasource.provider';
8
+ function buildTranslationKey(overrides = {}) {
9
+ return {
10
+ id: 'tkey-uuid-1',
11
+ module: 'auth',
12
+ key: 'auth.login.success',
13
+ defaultMessage: 'Login successful',
14
+ description: null,
15
+ variables: null,
16
+ isActive: true,
17
+ isReadonly: false,
18
+ serial: 1,
19
+ createdAt: new Date(),
20
+ updatedAt: new Date(),
21
+ deletedAt: null,
22
+ createdById: null,
23
+ updatedById: null,
24
+ deletedById: null,
25
+ ...overrides
26
+ };
27
+ }
28
+ describe('TranslationKeyService', ()=>{
29
+ let service;
30
+ let mockRepo;
31
+ let qb;
32
+ let queryRunner;
33
+ beforeEach(async ()=>{
34
+ mockRepo = createMockRepository();
35
+ qb = mockRepo.createQueryBuilder();
36
+ const mockDataSourceProvider = createMockDataSourceProvider(mockRepo);
37
+ // executeInTransaction()/delete() always grab a QueryRunner off repository.manager.connection
38
+ // before running the before*Operation hooks, so every test (success or rejection) needs one.
39
+ queryRunner = {
40
+ connect: jest.fn(),
41
+ startTransaction: jest.fn(),
42
+ commitTransaction: jest.fn(),
43
+ rollbackTransaction: jest.fn(),
44
+ release: jest.fn(),
45
+ manager: {
46
+ save: jest.fn(),
47
+ softDelete: jest.fn(),
48
+ update: jest.fn(),
49
+ delete: jest.fn(),
50
+ restore: jest.fn()
51
+ }
52
+ };
53
+ mockRepo.manager.connection.createQueryRunner.mockReturnValue(queryRunner);
54
+ const mockUtilsService = {
55
+ clearCache: jest.fn(),
56
+ getCacheKey: jest.fn().mockReturnValue('cache-key'),
57
+ trackCacheKey: jest.fn()
58
+ };
59
+ const module = await Test.createTestingModule({
60
+ providers: [
61
+ TranslationKeyService,
62
+ {
63
+ provide: 'CACHE_INSTANCE',
64
+ useValue: {
65
+ get: jest.fn(),
66
+ set: jest.fn()
67
+ }
68
+ },
69
+ {
70
+ provide: UtilsService,
71
+ useValue: mockUtilsService
72
+ },
73
+ {
74
+ provide: LocalizationDataSourceProvider,
75
+ useValue: mockDataSourceProvider
76
+ }
77
+ ]
78
+ }).compile();
79
+ service = await module.resolve(TranslationKeyService);
80
+ });
81
+ describe('findByModule', ()=>{
82
+ it('returns active, non-deleted keys for the module ordered by serial then key', async ()=>{
83
+ const keys = [
84
+ buildTranslationKey({
85
+ key: 'auth.login.success',
86
+ serial: 1
87
+ })
88
+ ];
89
+ mockRepo.find.mockResolvedValue(keys);
90
+ const result = await service.findByModule('auth');
91
+ expect(mockRepo.find).toHaveBeenCalledWith({
92
+ where: {
93
+ module: 'auth',
94
+ isActive: true,
95
+ deletedAt: expect.anything()
96
+ },
97
+ order: {
98
+ serial: 'ASC',
99
+ key: 'ASC'
100
+ }
101
+ });
102
+ expect(result).toEqual(keys);
103
+ });
104
+ it('returns an empty array for an unknown module', async ()=>{
105
+ mockRepo.find.mockResolvedValue([]);
106
+ const result = await service.findByModule('does-not-exist');
107
+ expect(result).toEqual([]);
108
+ });
109
+ });
110
+ describe('getModules', ()=>{
111
+ it('returns distinct module names', async ()=>{
112
+ // `getRawMany` isn't part of the shared MockQueryBuilder — attach it for this raw-query path.
113
+ const getRawMany = jest.fn().mockResolvedValue([
114
+ {
115
+ module: 'auth'
116
+ },
117
+ {
118
+ module: 'shared'
119
+ }
120
+ ]);
121
+ qb.getRawMany = getRawMany;
122
+ const result = await service.getModules();
123
+ expect(result).toEqual([
124
+ 'auth',
125
+ 'shared'
126
+ ]);
127
+ });
128
+ it('returns an empty array when there are no translation keys yet', async ()=>{
129
+ const getRawMany = jest.fn().mockResolvedValue([]);
130
+ qb.getRawMany = getRawMany;
131
+ const result = await service.getModules();
132
+ expect(result).toEqual([]);
133
+ });
134
+ });
135
+ describe('beforeInsertOperation (via insert) — duplicate key validation', ()=>{
136
+ it('allows insert when no key with the same module+key exists', async ()=>{
137
+ qb.getOne.mockResolvedValue(null);
138
+ const created = buildTranslationKey();
139
+ mockRepo.create.mockReturnValue({});
140
+ queryRunner.manager.save.mockResolvedValue(created);
141
+ await expect(service.insert({
142
+ module: 'auth',
143
+ key: 'auth.login.success',
144
+ defaultMessage: 'Login successful'
145
+ }, buildMockUser())).resolves.toBeDefined();
146
+ });
147
+ it('rejects with BadRequestException + messageKey when the module+key combination already exists', async ()=>{
148
+ qb.getOne.mockResolvedValue(buildTranslationKey());
149
+ await expect(service.insert({
150
+ module: 'auth',
151
+ key: 'auth.login.success',
152
+ defaultMessage: 'Login successful'
153
+ }, buildMockUser())).rejects.toMatchObject({
154
+ response: expect.objectContaining({
155
+ messageKey: 'translation.key.duplicate.key.in.module'
156
+ })
157
+ });
158
+ });
159
+ it('rejects with BadRequestException for duplicate module+key in insertMany', async ()=>{
160
+ qb.getOne.mockResolvedValueOnce(null).mockResolvedValueOnce(buildTranslationKey({
161
+ key: 'auth.logout.success'
162
+ }));
163
+ await expect(service.insertMany([
164
+ {
165
+ module: 'auth',
166
+ key: 'auth.login.success',
167
+ defaultMessage: 'A'
168
+ },
169
+ {
170
+ module: 'auth',
171
+ key: 'auth.logout.success',
172
+ defaultMessage: 'B'
173
+ }
174
+ ], buildMockUser())).rejects.toBeInstanceOf(BadRequestException);
175
+ });
176
+ });
177
+ describe('beforeUpdateOperation (via update)', ()=>{
178
+ it('rejects with ForbiddenException when modifying key/module of a readonly translation key', async ()=>{
179
+ const existing = buildTranslationKey({
180
+ id: 'tkey-1',
181
+ isReadonly: true,
182
+ key: 'system.ready'
183
+ });
184
+ mockRepo.find.mockResolvedValue([
185
+ existing
186
+ ]);
187
+ await expect(service.update({
188
+ id: 'tkey-1',
189
+ key: 'system.changed'
190
+ }, buildMockUser())).rejects.toBeInstanceOf(ForbiddenException);
191
+ await expect(service.update({
192
+ id: 'tkey-1',
193
+ key: 'system.changed'
194
+ }, buildMockUser())).rejects.toMatchObject({
195
+ response: expect.objectContaining({
196
+ messageKey: 'translation.key.readonly.update.forbidden'
197
+ })
198
+ });
199
+ });
200
+ it('allows updating non key/module fields of a readonly translation key', async ()=>{
201
+ const existing = buildTranslationKey({
202
+ id: 'tkey-1',
203
+ isReadonly: true,
204
+ key: 'system.ready'
205
+ });
206
+ mockRepo.find.mockResolvedValue([
207
+ existing
208
+ ]);
209
+ mockRepo.findOne.mockResolvedValue(existing);
210
+ queryRunner.manager.save.mockResolvedValue(existing);
211
+ await expect(service.update({
212
+ id: 'tkey-1',
213
+ defaultMessage: 'Updated readonly message'
214
+ }, buildMockUser())).resolves.toBeDefined();
215
+ });
216
+ it('rejects with BadRequestException when the new key/module already exists on another row', async ()=>{
217
+ const existing = buildTranslationKey({
218
+ id: 'tkey-1',
219
+ isReadonly: false,
220
+ key: 'auth.old',
221
+ module: 'auth'
222
+ });
223
+ mockRepo.find.mockResolvedValue([
224
+ existing
225
+ ]);
226
+ qb.getOne.mockResolvedValue(buildTranslationKey({
227
+ id: 'tkey-other',
228
+ key: 'auth.new'
229
+ }));
230
+ await expect(service.update({
231
+ id: 'tkey-1',
232
+ key: 'auth.new'
233
+ }, buildMockUser())).rejects.toMatchObject({
234
+ response: expect.objectContaining({
235
+ messageKey: 'translation.key.duplicate.key.in.module'
236
+ })
237
+ });
238
+ // excludeId must be used so the row being updated doesn't collide with itself
239
+ expect(qb.andWhere).toHaveBeenCalledWith('tk.id != :excludeId', {
240
+ excludeId: 'tkey-1'
241
+ });
242
+ });
243
+ });
244
+ describe('beforeDeleteOperation (via delete)', ()=>{
245
+ it('rejects with ForbiddenException + messageKey when any target id is readonly', async ()=>{
246
+ const readonlyKey = buildTranslationKey({
247
+ id: 'tkey-1',
248
+ isReadonly: true,
249
+ key: 'system.core'
250
+ });
251
+ mockRepo.find.mockResolvedValue([
252
+ readonlyKey
253
+ ]);
254
+ const dto = {
255
+ id: [
256
+ 'tkey-1'
257
+ ],
258
+ type: 'delete'
259
+ };
260
+ await expect(service.delete(dto, buildMockUser())).rejects.toMatchObject({
261
+ response: expect.objectContaining({
262
+ messageKey: 'translation.key.readonly.delete.forbidden'
263
+ })
264
+ });
265
+ });
266
+ it('allows delete when none of the targeted ids are readonly', async ()=>{
267
+ mockRepo.find.mockResolvedValue([]);
268
+ const dto = {
269
+ id: [
270
+ 'tkey-1'
271
+ ],
272
+ type: 'delete'
273
+ };
274
+ await expect(service.delete(dto, buildMockUser())).resolves.toBeNull();
275
+ expect(queryRunner.manager.softDelete).toHaveBeenCalled();
276
+ });
277
+ });
278
+ });
@@ -0,0 +1,422 @@
1
+ import { Test } from '@nestjs/testing';
2
+ import { UtilsService } from '@flusys/nestjs-shared/modules';
3
+ import { createMockDataSourceProvider, createMockRepository } from '@test-utils/mocks/repository.mock';
4
+ import { Language, Translation, TranslationKey } from '../entities';
5
+ import { TranslationService } from './translation.service';
6
+ import { LocalizationDataSourceProvider } from './localization-datasource.provider';
7
+ function buildLanguage(overrides = {}) {
8
+ return {
9
+ id: 'lang-en',
10
+ code: 'en',
11
+ name: 'English',
12
+ nativeName: 'English',
13
+ direction: 'ltr',
14
+ isActive: true,
15
+ isDefault: true,
16
+ serial: 1,
17
+ isReadonly: false,
18
+ createdAt: new Date(),
19
+ updatedAt: new Date(),
20
+ deletedAt: null,
21
+ createdById: null,
22
+ updatedById: null,
23
+ deletedById: null,
24
+ ...overrides
25
+ };
26
+ }
27
+ function buildTranslationKey(overrides = {}) {
28
+ return {
29
+ id: 'tkey-1',
30
+ module: 'auth',
31
+ key: 'auth.login.success',
32
+ defaultMessage: 'Login successful',
33
+ description: null,
34
+ variables: null,
35
+ isActive: true,
36
+ isReadonly: false,
37
+ serial: 1,
38
+ createdAt: new Date(),
39
+ updatedAt: new Date(),
40
+ deletedAt: null,
41
+ createdById: null,
42
+ updatedById: null,
43
+ deletedById: null,
44
+ ...overrides
45
+ };
46
+ }
47
+ function buildTranslation(overrides = {}) {
48
+ return {
49
+ id: 'translation-1',
50
+ languageId: 'lang-en',
51
+ translationKeyId: 'tkey-1',
52
+ value: 'Login successful',
53
+ isVerified: false,
54
+ isActive: true,
55
+ createdAt: new Date(),
56
+ updatedAt: new Date(),
57
+ deletedAt: null,
58
+ createdById: null,
59
+ updatedById: null,
60
+ deletedById: null,
61
+ ...overrides
62
+ };
63
+ }
64
+ describe('TranslationService', ()=>{
65
+ let service;
66
+ let mockRepo;
67
+ // `TranslationService` uses joins/aggregate query-builder methods (innerJoin, innerJoinAndSelect,
68
+ // groupBy, addSelect, getRawMany) that aren't part of the shared MockQueryBuilder — extend loosely.
69
+ let qb;
70
+ let languageRepo;
71
+ let translationKeyRepo;
72
+ let fakeDataSource;
73
+ beforeEach(async ()=>{
74
+ mockRepo = createMockRepository();
75
+ qb = mockRepo.createQueryBuilder();
76
+ qb.innerJoin = jest.fn().mockReturnThis();
77
+ qb.innerJoinAndSelect = jest.fn().mockReturnThis();
78
+ qb.groupBy = jest.fn().mockReturnThis();
79
+ qb.addSelect = jest.fn().mockReturnThis();
80
+ qb.getRawMany = jest.fn().mockResolvedValue([]);
81
+ const mockDataSourceProvider = createMockDataSourceProvider(mockRepo);
82
+ languageRepo = {
83
+ findOne: jest.fn()
84
+ };
85
+ translationKeyRepo = {
86
+ find: jest.fn()
87
+ };
88
+ fakeDataSource = {
89
+ getRepository: jest.fn((entity)=>{
90
+ if (entity === Language) return languageRepo;
91
+ if (entity === TranslationKey) return translationKeyRepo;
92
+ return mockRepo;
93
+ }),
94
+ createQueryRunner: jest.fn()
95
+ };
96
+ mockDataSourceProvider.getDataSource.mockResolvedValue(fakeDataSource);
97
+ const mockUtilsService = {
98
+ clearCache: jest.fn(),
99
+ getCacheKey: jest.fn().mockReturnValue('cache-key'),
100
+ trackCacheKey: jest.fn()
101
+ };
102
+ const module = await Test.createTestingModule({
103
+ providers: [
104
+ TranslationService,
105
+ {
106
+ provide: 'CACHE_INSTANCE',
107
+ useValue: {}
108
+ },
109
+ {
110
+ provide: UtilsService,
111
+ useValue: mockUtilsService
112
+ },
113
+ {
114
+ provide: LocalizationDataSourceProvider,
115
+ useValue: mockDataSourceProvider
116
+ }
117
+ ]
118
+ }).compile();
119
+ service = await module.resolve(TranslationService);
120
+ });
121
+ describe('getByLanguageCode', ()=>{
122
+ it('returns an empty translations map when the language code is unknown', async ()=>{
123
+ languageRepo.findOne.mockResolvedValue(null);
124
+ const result = await service.getByLanguageCode('xx');
125
+ expect(result).toEqual({});
126
+ expect(qb.getMany).not.toHaveBeenCalled();
127
+ });
128
+ it('builds a translations map keyed by translation key, scoped to the active language', async ()=>{
129
+ languageRepo.findOne.mockResolvedValue(buildLanguage());
130
+ const translations = [
131
+ {
132
+ ...buildTranslation({
133
+ value: 'Login successful'
134
+ }),
135
+ translationKey: buildTranslationKey({
136
+ key: 'auth.login.success'
137
+ })
138
+ },
139
+ {
140
+ ...buildTranslation({
141
+ id: 'translation-2',
142
+ value: 'Logout successful'
143
+ }),
144
+ translationKey: buildTranslationKey({
145
+ id: 'tkey-2',
146
+ key: 'auth.logout.success'
147
+ })
148
+ }
149
+ ];
150
+ qb.getMany.mockResolvedValue(translations);
151
+ const result = await service.getByLanguageCode('en');
152
+ expect(languageRepo.findOne).toHaveBeenCalledWith({
153
+ where: {
154
+ code: 'en',
155
+ isActive: true,
156
+ deletedAt: expect.anything()
157
+ }
158
+ });
159
+ expect(result).toEqual({
160
+ 'auth.login.success': 'Login successful',
161
+ 'auth.logout.success': 'Logout successful'
162
+ });
163
+ });
164
+ it('scopes translations by module when modules are supplied (module-based lazy loading)', async ()=>{
165
+ languageRepo.findOne.mockResolvedValue(buildLanguage());
166
+ qb.getMany.mockResolvedValue([]);
167
+ await service.getByLanguageCode('en', [
168
+ 'auth',
169
+ 'shared'
170
+ ]);
171
+ expect(qb.andWhere).toHaveBeenCalledWith('tk.module IN (:...modules)', {
172
+ modules: [
173
+ 'auth',
174
+ 'shared'
175
+ ]
176
+ });
177
+ });
178
+ it('does not apply a module filter when the modules array is empty', async ()=>{
179
+ languageRepo.findOne.mockResolvedValue(buildLanguage());
180
+ qb.getMany.mockResolvedValue([]);
181
+ await service.getByLanguageCode('en', []);
182
+ expect(qb.andWhere).not.toHaveBeenCalledWith(expect.stringContaining('tk.module IN'), expect.anything());
183
+ });
184
+ it('silently drops translations whose translationKey relation failed to join (fallback: key omitted, no throw)', async ()=>{
185
+ languageRepo.findOne.mockResolvedValue(buildLanguage());
186
+ qb.getMany.mockResolvedValue([
187
+ {
188
+ ...buildTranslation(),
189
+ translationKey: undefined
190
+ }
191
+ ]);
192
+ const result = await service.getByLanguageCode('en');
193
+ expect(result).toEqual({});
194
+ });
195
+ });
196
+ describe('getByModuleAndLanguage', ()=>{
197
+ it('returns an empty map for an unknown language code', async ()=>{
198
+ languageRepo.findOne.mockResolvedValue(null);
199
+ const result = await service.getByModuleAndLanguage('auth', 'xx');
200
+ expect(result).toEqual({});
201
+ });
202
+ it('filters translations to the requested module only', async ()=>{
203
+ languageRepo.findOne.mockResolvedValue(buildLanguage());
204
+ qb.getMany.mockResolvedValue([
205
+ {
206
+ ...buildTranslation(),
207
+ translationKey: buildTranslationKey({
208
+ key: 'auth.login.success'
209
+ })
210
+ }
211
+ ]);
212
+ const result = await service.getByModuleAndLanguage('auth', 'en');
213
+ expect(qb.andWhere).toHaveBeenCalledWith('tk.module = :module', {
214
+ module: 'auth'
215
+ });
216
+ expect(result).toEqual({
217
+ 'auth.login.success': 'Login successful'
218
+ });
219
+ });
220
+ });
221
+ describe('bulkUpdate', ()=>{
222
+ function buildQueryRunner() {
223
+ return {
224
+ connect: jest.fn(),
225
+ startTransaction: jest.fn(),
226
+ commitTransaction: jest.fn(),
227
+ rollbackTransaction: jest.fn(),
228
+ release: jest.fn(),
229
+ manager: {
230
+ update: jest.fn(),
231
+ create: jest.fn((_entity, data)=>({
232
+ id: 'new-translation-id',
233
+ ...data
234
+ })),
235
+ save: jest.fn(async (entity)=>entity)
236
+ }
237
+ };
238
+ }
239
+ it('returns an empty array without opening a transaction when there are no updates', async ()=>{
240
+ const result = await service.bulkUpdate([]);
241
+ expect(result).toEqual([]);
242
+ expect(fakeDataSource.createQueryRunner).not.toHaveBeenCalled();
243
+ });
244
+ it('updates existing translations by id and defaults isVerified to false', async ()=>{
245
+ const queryRunner = buildQueryRunner();
246
+ fakeDataSource.createQueryRunner.mockReturnValue(queryRunner);
247
+ mockRepo.find.mockResolvedValue([
248
+ buildTranslation({
249
+ id: 'translation-1'
250
+ })
251
+ ]);
252
+ const result = await service.bulkUpdate([
253
+ {
254
+ id: 'translation-1',
255
+ languageId: 'lang-en',
256
+ translationKeyId: 'tkey-1',
257
+ value: 'Updated'
258
+ }
259
+ ]);
260
+ expect(queryRunner.manager.update).toHaveBeenCalledWith(Translation, {
261
+ id: 'translation-1'
262
+ }, {
263
+ value: 'Updated',
264
+ isVerified: false
265
+ });
266
+ expect(queryRunner.commitTransaction).toHaveBeenCalled();
267
+ expect(mockRepo.find).toHaveBeenCalledWith({
268
+ where: {
269
+ id: expect.anything()
270
+ }
271
+ });
272
+ expect(result).toHaveLength(1);
273
+ });
274
+ it('creates new translations when no id is provided', async ()=>{
275
+ const queryRunner = buildQueryRunner();
276
+ fakeDataSource.createQueryRunner.mockReturnValue(queryRunner);
277
+ mockRepo.find.mockResolvedValue([
278
+ buildTranslation({
279
+ id: 'new-translation-id'
280
+ })
281
+ ]);
282
+ await service.bulkUpdate([
283
+ {
284
+ languageId: 'lang-en',
285
+ translationKeyId: 'tkey-1',
286
+ value: 'Brand new',
287
+ isVerified: true
288
+ }
289
+ ]);
290
+ expect(queryRunner.manager.create).toHaveBeenCalledWith(Translation, {
291
+ languageId: 'lang-en',
292
+ translationKeyId: 'tkey-1',
293
+ value: 'Brand new',
294
+ isVerified: true,
295
+ isActive: true
296
+ });
297
+ expect(queryRunner.manager.save).toHaveBeenCalled();
298
+ expect(queryRunner.commitTransaction).toHaveBeenCalled();
299
+ });
300
+ it('rolls back the transaction and rethrows the original error when a write fails', async ()=>{
301
+ const queryRunner = buildQueryRunner();
302
+ queryRunner.manager.update.mockRejectedValue(new Error('db write failed'));
303
+ fakeDataSource.createQueryRunner.mockReturnValue(queryRunner);
304
+ await expect(service.bulkUpdate([
305
+ {
306
+ id: 'translation-1',
307
+ languageId: 'lang-en',
308
+ translationKeyId: 'tkey-1',
309
+ value: 'x'
310
+ }
311
+ ])).rejects.toThrow('db write failed');
312
+ expect(queryRunner.rollbackTransaction).toHaveBeenCalled();
313
+ expect(queryRunner.release).toHaveBeenCalled();
314
+ expect(queryRunner.commitTransaction).not.toHaveBeenCalled();
315
+ });
316
+ });
317
+ describe('getMissingTranslations', ()=>{
318
+ it('returns an empty array for an unknown language code', async ()=>{
319
+ languageRepo.findOne.mockResolvedValue(null);
320
+ const result = await service.getMissingTranslations('xx');
321
+ expect(result).toEqual([]);
322
+ expect(translationKeyRepo.find).not.toHaveBeenCalled();
323
+ });
324
+ it('returns translation keys that have no translation row yet for the language', async ()=>{
325
+ languageRepo.findOne.mockResolvedValue(buildLanguage());
326
+ const allKeys = [
327
+ buildTranslationKey({
328
+ id: 'tkey-1',
329
+ key: 'auth.login.success'
330
+ }),
331
+ buildTranslationKey({
332
+ id: 'tkey-2',
333
+ key: 'auth.logout.success'
334
+ })
335
+ ];
336
+ translationKeyRepo.find.mockResolvedValue(allKeys);
337
+ mockRepo.find.mockResolvedValue([
338
+ {
339
+ translationKeyId: 'tkey-1'
340
+ }
341
+ ]);
342
+ const result = await service.getMissingTranslations('en');
343
+ expect(result).toEqual([
344
+ allKeys[1]
345
+ ]);
346
+ });
347
+ it('returns an empty array when every translation key already has a translation', async ()=>{
348
+ languageRepo.findOne.mockResolvedValue(buildLanguage());
349
+ const allKeys = [
350
+ buildTranslationKey({
351
+ id: 'tkey-1'
352
+ })
353
+ ];
354
+ translationKeyRepo.find.mockResolvedValue(allKeys);
355
+ mockRepo.find.mockResolvedValue([
356
+ {
357
+ translationKeyId: 'tkey-1'
358
+ }
359
+ ]);
360
+ const result = await service.getMissingTranslations('en');
361
+ expect(result).toEqual([]);
362
+ });
363
+ });
364
+ describe('getTranslationCounts', ()=>{
365
+ it('returns a map of translationKeyId -> translation count', async ()=>{
366
+ qb.getRawMany = jest.fn().mockResolvedValue([
367
+ {
368
+ keyId: 'tkey-1',
369
+ count: '3'
370
+ },
371
+ {
372
+ keyId: 'tkey-2',
373
+ count: '1'
374
+ }
375
+ ]);
376
+ const result = await service.getTranslationCounts();
377
+ expect(result).toEqual({
378
+ 'tkey-1': 3,
379
+ 'tkey-2': 1
380
+ });
381
+ });
382
+ it('returns an empty object when there are no translations', async ()=>{
383
+ qb.getRawMany = jest.fn().mockResolvedValue([]);
384
+ const result = await service.getTranslationCounts();
385
+ expect(result).toEqual({});
386
+ });
387
+ });
388
+ describe('getFilterQuery (module/language/key filtering)', ()=>{
389
+ it('joins translation_key and filters by module when filter.module is provided', async ()=>{
390
+ const { query, isRaw } = await service.getFilterQuery(qb, {
391
+ module: 'auth'
392
+ }, null);
393
+ expect(qb.innerJoin).toHaveBeenCalledWith('translation.translationKey', 'tk');
394
+ expect(qb.andWhere).toHaveBeenCalledWith('tk.module = :module', {
395
+ module: 'auth'
396
+ });
397
+ expect(isRaw).toBe(false);
398
+ expect(query).toBe(qb);
399
+ });
400
+ it('filters by languageId without requiring a join', async ()=>{
401
+ await service.getFilterQuery(qb, {
402
+ languageId: 'lang-en'
403
+ }, null);
404
+ expect(qb.andWhere).toHaveBeenCalledWith('translation.languageId = :languageId', {
405
+ languageId: 'lang-en'
406
+ });
407
+ });
408
+ it('filters by translationKeyId', async ()=>{
409
+ await service.getFilterQuery(qb, {
410
+ translationKeyId: 'tkey-1'
411
+ }, null);
412
+ expect(qb.andWhere).toHaveBeenCalledWith('translation.translationKeyId = :translationKeyId', {
413
+ translationKeyId: 'tkey-1'
414
+ });
415
+ });
416
+ it('applies no filters when the filter object is empty', async ()=>{
417
+ await service.getFilterQuery(qb, {}, null);
418
+ expect(qb.innerJoin).not.toHaveBeenCalled();
419
+ expect(qb.andWhere).not.toHaveBeenCalled();
420
+ });
421
+ });
422
+ });